From 438aee05774f3cb6b7397fa17df7b49ea2c915ed Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 21 May 2024 10:45:58 -0400 Subject: [PATCH 001/163] Revert "Add `vitest-preview` package and document usage (#10481)" (#10494) This reverts commit 95141e9b1691544f347ab15f7307d4de7ddb5fff. --- .gitignore | 5 +- docs/development-guide/08-testing.md | 23 -- package.json | 1 - packages/manager/package.json | 4 +- packages/manager/vite.config.ts | 7 +- yarn.lock | 382 ++++----------------------- 6 files changed, 58 insertions(+), 364 deletions(-) diff --git a/.gitignore b/.gitignore index 9344cdda1b3..933adffc825 100644 --- a/.gitignore +++ b/.gitignore @@ -141,7 +141,4 @@ packages/manager/bundle_analyzer_report.html **/manager/src/dev-tools/*.local.* # vitepress -docs/.vitepress/cache - -# vitest-preview -.vitest-preview \ No newline at end of file +docs/.vitepress/cache \ No newline at end of file diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index d0794f08194..caf87e86060 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -43,29 +43,6 @@ yarn workspace linode-manager run test:debug Test execution will stop at the debugger statement, and you will be able to use Chrome's normal debugger to step through the tests (open `chrome://inspect/#devices` in Chrome). -### Visual debugging - -Using `vite-preview`, you can view a preview of the tested component in the browser. - -First, add the following lines to your test: - -``` -import { debug } from 'vitest-preview'; - -// Inside your tests -describe('my test', () => { - render(); - debug(); // 👈 Add this line -} -``` - -Start the `vitest-preview` server: -``` -yarn vitest-preview -``` - -Finally, run the test to view the component in the browser. - ### React Testing Library This library provides a set of tools to render React components from within the Vitest environment. The library's philosophy is that components should be tested as closely as possible to how they are used. diff --git a/package.json b/package.json index 1ee6cef250a..4c7b5804b7d 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "start:manager:ci": "yarn workspace linode-manager start:ci", "clean": "rm -rf node_modules && rm -rf packages/@linode/api-v4/node_modules && rm -rf packages/manager/node_modules && rm -rf packages/@linode/validation/node_modules", "test": "yarn workspace linode-manager test", - "vitest-preview": "yarn workspace linode-manager vitest-preview", "package-versions": "node ./scripts/package-versions/index.js", "storybook": "yarn workspace linode-manager storybook", "cy:run": "yarn workspace linode-manager cy:run", diff --git a/packages/manager/package.json b/packages/manager/package.json index ede2b9a9049..1b3bd43570e 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -94,7 +94,6 @@ "precommit": "lint-staged && yarn typecheck", "test": "vitest run", "test:debug": "node --inspect-brk scripts/test.js --runInBand", - "vitest-preview": "vitest-preview", "storybook": "storybook dev -p 6006", "storybook-static": "storybook build -c .storybook -o .out", "build-storybook": "storybook build", @@ -213,8 +212,7 @@ "ts-node": "^10.9.2", "vite": "^5.1.7", "vite-plugin-svgr": "^3.2.0", - "vitest": "^1.2.0", - "vitest-preview": "^0.0.1" + "vitest": "^1.2.0" }, "browserslist": [ ">1%", diff --git a/packages/manager/vite.config.ts b/packages/manager/vite.config.ts index 066503d84cd..614c59091d3 100644 --- a/packages/manager/vite.config.ts +++ b/packages/manager/vite.config.ts @@ -1,6 +1,7 @@ import react from '@vitejs/plugin-react-swc'; import svgr from 'vite-plugin-svgr'; import { defineConfig } from 'vitest/config'; +import { URL } from 'url'; // ESM-friendly alternative to `__dirname`. const DIRNAME = new URL('.', import.meta.url).pathname; @@ -10,11 +11,6 @@ export default defineConfig({ outDir: 'build', }, envPrefix: 'REACT_APP_', - optimizeDeps: { - esbuildOptions: { - target: 'es5', - }, - }, plugins: [react(), svgr({ exportAsDefault: true })], resolve: { alias: { @@ -39,7 +35,6 @@ export default defineConfig({ 'src/**/*.utils.{js,jsx,ts,tsx}', ], }, - css: true, environment: 'jsdom', globals: true, setupFiles: './src/testSetup.ts', diff --git a/yarn.lock b/yarn.lock index 555ff26ec05..c07bd45ecc9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1333,7 +1333,7 @@ "@babel/parser" "^7.24.0" "@babel/types" "^7.24.0" -"@babel/traverse@^7.18.9", "@babel/traverse@^7.23.9", "@babel/traverse@^7.24.1", "@babel/traverse@^7.7.0": +"@babel/traverse@^7.18.9", "@babel/traverse@^7.23.3", "@babel/traverse@^7.23.9", "@babel/traverse@^7.24.1", "@babel/traverse@^7.24.5", "@babel/traverse@^7.7.0": version "7.24.1" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.1.tgz#d65c36ac9dd17282175d1e4a3c49d5b7988f530c" integrity sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ== @@ -1349,22 +1349,6 @@ debug "^4.3.1" globals "^11.1.0" -"@babel/traverse@^7.24.5": - version "7.24.5" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.5.tgz#972aa0bc45f16983bf64aa1f877b2dd0eea7e6f8" - integrity sha512-7aaBLeDQ4zYcUFDUD41lJc1fG8+5IU9DaNSJAgal866FGvmD5EbWQgnEC6kO1gGLsX0esNkfnJSndbTXA3r7UA== - dependencies: - "@babel/code-frame" "^7.24.2" - "@babel/generator" "^7.24.5" - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-function-name" "^7.23.0" - "@babel/helper-hoist-variables" "^7.22.5" - "@babel/helper-split-export-declaration" "^7.24.5" - "@babel/parser" "^7.24.5" - "@babel/types" "^7.24.5" - debug "^4.3.1" - globals "^11.1.0" - "@babel/types@^7.0.0", "@babel/types@^7.18.9", "@babel/types@^7.20.7", "@babel/types@^7.21.3", "@babel/types@^7.22.15", "@babel/types@^7.22.19", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.23.6", "@babel/types@^7.23.9", "@babel/types@^7.4.4", "@babel/types@^7.7.0": version "7.23.9" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.9.tgz#1dd7b59a9a2b5c87f8b41e52770b5ecbf492e002" @@ -1716,11 +1700,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz#db1c9202a5bc92ea04c7b6840f1bbe09ebf9e6b9" integrity sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg== -"@esbuild/android-arm@0.15.18": - version "0.15.18" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.15.18.tgz#266d40b8fdcf87962df8af05b76219bc786b4f80" - integrity sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw== - "@esbuild/android-arm@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.12.tgz#b0c26536f37776162ca8bde25e42040c203f2824" @@ -1811,11 +1790,6 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz#c0e5e787c285264e5dfc7a79f04b8b4eefdad7fa" integrity sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig== -"@esbuild/linux-loong64@0.15.18": - version "0.15.18" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz#128b76ecb9be48b60cf5cfc1c63a4f00691a3239" - integrity sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ== - "@esbuild/linux-loong64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz#9a37f87fec4b8408e682b528391fa22afd952299" @@ -2057,18 +2031,6 @@ resolved "https://registry.yarnpkg.com/@inquirer/type/-/type-1.2.1.tgz#fbc7ab3a2e5050d0c150642d5e8f5e88faa066b8" integrity sha512-xwMfkPAxeo8Ji/IxfUSqzRi0/+F2GIqJmpc5/thelgMGsjNZcjDDRBO9TLXT1s/hdx/mK5QbVIvgoLIFgXhTMQ== -"@isaacs/cliui@^8.0.2": - version "8.0.2" - resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" - integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== - dependencies: - string-width "^5.1.2" - string-width-cjs "npm:string-width@^4.2.0" - strip-ansi "^7.0.1" - strip-ansi-cjs "npm:strip-ansi@^6.0.1" - wrap-ansi "^8.1.0" - wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" - "@istanbuljs/schema@^0.1.2": version "0.1.3" resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" @@ -3867,7 +3829,7 @@ "@types/range-parser" "*" "@types/send" "*" -"@types/express@^4.17.14", "@types/express@^4.7.0": +"@types/express@^4.7.0": version "4.17.21" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.21.tgz#c26d4a151e60efe0084b23dc3369ebc631ed192d" integrity sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ== @@ -4085,13 +4047,6 @@ dependencies: undici-types "~5.26.4" -"@types/node@^18.11.3": - version "18.19.33" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.33.tgz#98cd286a1b8a5e11aa06623210240bcc28e95c48" - integrity sha512-NR9+KrpSajr2qBVp/Yt5TU/rp+b5Mayi3+OlMlcg2cVCfRmcG5PWZ7S4+MG9PZ5gWBoc9Pd0BKSRViuBCRPu0A== - dependencies: - undici-types "~5.26.4" - "@types/node@^20.11.26": version "20.11.27" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.27.tgz#debe5cfc8a507dd60fe2a3b4875b1604f215c2ac" @@ -4532,13 +4487,6 @@ dependencies: "@swc/core" "^1.3.107" -"@vitest-preview/dev-utils@0.0.1": - version "0.0.1" - resolved "https://registry.yarnpkg.com/@vitest-preview/dev-utils/-/dev-utils-0.0.1.tgz#c6cbd97a37f331478e6bba5db23715d2a9fea0a1" - integrity sha512-KLr4IvFz73dMao1tCHWgwqNJfHEcGOqHaQ7SHYfumrMvs2BBD4PKMBtePO2AV7+gq4iEPuIJY8INR3Oq5EnTUw== - dependencies: - open "^8.4.0" - "@vitest/coverage-v8@^1.0.4": version "1.2.2" resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-1.2.2.tgz#681f4f76de896d0d2484cca32285477e288fec3a" @@ -5432,11 +5380,6 @@ callsites@^3.0.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== -camelcase@^5.0.0: - version "5.3.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" - integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== - camelcase@^6.2.0: version "6.3.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" @@ -6628,13 +6571,6 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== -encoding@^0.1.11: - version "0.1.13" - resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" - integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== - dependencies: - iconv-lite "^0.6.2" - end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -6787,86 +6723,6 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -esbuild-android-64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz#20a7ae1416c8eaade917fb2453c1259302c637a5" - integrity sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA== - -esbuild-android-arm64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz#9cc0ec60581d6ad267568f29cf4895ffdd9f2f04" - integrity sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ== - -esbuild-darwin-64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz#428e1730ea819d500808f220fbc5207aea6d4410" - integrity sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg== - -esbuild-darwin-arm64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz#b6dfc7799115a2917f35970bfbc93ae50256b337" - integrity sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA== - -esbuild-freebsd-64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz#4e190d9c2d1e67164619ae30a438be87d5eedaf2" - integrity sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA== - -esbuild-freebsd-arm64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz#18a4c0344ee23bd5a6d06d18c76e2fd6d3f91635" - integrity sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA== - -esbuild-linux-32@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz#9a329731ee079b12262b793fb84eea762e82e0ce" - integrity sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg== - -esbuild-linux-64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz#532738075397b994467b514e524aeb520c191b6c" - integrity sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw== - -esbuild-linux-arm64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz#5372e7993ac2da8f06b2ba313710d722b7a86e5d" - integrity sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug== - -esbuild-linux-arm@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz#e734aaf259a2e3d109d4886c9e81ec0f2fd9a9cc" - integrity sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA== - -esbuild-linux-mips64le@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz#c0487c14a9371a84eb08fab0e1d7b045a77105eb" - integrity sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ== - -esbuild-linux-ppc64le@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz#af048ad94eed0ce32f6d5a873f7abe9115012507" - integrity sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w== - -esbuild-linux-riscv64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz#423ed4e5927bd77f842bd566972178f424d455e6" - integrity sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg== - -esbuild-linux-s390x@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz#21d21eaa962a183bfb76312e5a01cc5ae48ce8eb" - integrity sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ== - -esbuild-netbsd-64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz#ae75682f60d08560b1fe9482bfe0173e5110b998" - integrity sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg== - -esbuild-openbsd-64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz#79591a90aa3b03e4863f93beec0d2bab2853d0a8" - integrity sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ== - esbuild-plugin-alias@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/esbuild-plugin-alias/-/esbuild-plugin-alias-0.2.1.tgz#45a86cb941e20e7c2bc68a2bea53562172494fcb" @@ -6879,54 +6735,6 @@ esbuild-register@^3.5.0: dependencies: debug "^4.3.4" -esbuild-sunos-64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz#fd528aa5da5374b7e1e93d36ef9b07c3dfed2971" - integrity sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw== - -esbuild-windows-32@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz#0e92b66ecdf5435a76813c4bc5ccda0696f4efc3" - integrity sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ== - -esbuild-windows-64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz#0fc761d785414284fc408e7914226d33f82420d0" - integrity sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw== - -esbuild-windows-arm64@0.15.18: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz#5b5bdc56d341d0922ee94965c89ee120a6a86eb7" - integrity sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ== - -esbuild@^0.15.9: - version "0.15.18" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.15.18.tgz#ea894adaf3fbc036d32320a00d4d6e4978a2f36d" - integrity sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q== - optionalDependencies: - "@esbuild/android-arm" "0.15.18" - "@esbuild/linux-loong64" "0.15.18" - esbuild-android-64 "0.15.18" - esbuild-android-arm64 "0.15.18" - esbuild-darwin-64 "0.15.18" - esbuild-darwin-arm64 "0.15.18" - esbuild-freebsd-64 "0.15.18" - esbuild-freebsd-arm64 "0.15.18" - esbuild-linux-32 "0.15.18" - esbuild-linux-64 "0.15.18" - esbuild-linux-arm "0.15.18" - esbuild-linux-arm64 "0.15.18" - esbuild-linux-mips64le "0.15.18" - esbuild-linux-ppc64le "0.15.18" - esbuild-linux-riscv64 "0.15.18" - esbuild-linux-s390x "0.15.18" - esbuild-netbsd-64 "0.15.18" - esbuild-openbsd-64 "0.15.18" - esbuild-sunos-64 "0.15.18" - esbuild-windows-32 "0.15.18" - esbuild-windows-64 "0.15.18" - esbuild-windows-arm64 "0.15.18" - "esbuild@^0.18.0 || ^0.19.0 || ^0.20.0": version "0.20.2" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.20.2.tgz#9d6b2386561766ee6b5a55196c6d766d28c87ea1" @@ -7470,7 +7278,7 @@ executable@^4.1.1: dependencies: pify "^2.2.0" -express@^4.17.3, express@^4.18.2: +express@^4.17.3: version "4.19.2" resolved "https://registry.yarnpkg.com/express/-/express-4.19.2.tgz#e25437827a3aa7f2a827bc8171bbbb664a356465" integrity sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q== @@ -8060,20 +7868,13 @@ github-slugger@^2.0.0: resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-2.0.0.tgz#52cf2f9279a21eb6c59dd385b410f0c0adda8f1a" integrity sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw== -glob-parent@^5.0.0, glob-parent@^5.1.2, glob-parent@~5.1.2: +glob-parent@^5.0.0, glob-parent@^5.1.2, glob-parent@^6.0.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== dependencies: is-glob "^4.0.1" -glob-parent@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" - integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== - dependencies: - is-glob "^4.0.3" - glob-promise@^4.2.0: version "4.2.2" resolved "https://registry.yarnpkg.com/glob-promise/-/glob-promise-4.2.2.tgz#15f44bcba0e14219cd93af36da6bb905ff007877" @@ -8343,10 +8144,12 @@ hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react- dependencies: react-is "^16.7.0" -hosted-git-info@^2.1.4: - version "2.8.9" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" - integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== +hosted-git-info@^2.1.4, hosted-git-info@^5.0.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-5.2.1.tgz#0ba1c97178ef91f3ab30842ae63d6a272341156f" + integrity sha512-xIcQYMnhcx2Nr4JTjsFmwwnr9vldugPy9uVm0o87bjqqWMv9GaqsTeT+i99wTl0mk1uLxJtHxLb8kymqTENQsw== + dependencies: + lru-cache "^7.5.1" html-encoding-sniffer@^3.0.0: version "3.0.0" @@ -8454,7 +8257,7 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@0.6.3, iconv-lite@^0.6.2: +iconv-lite@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== @@ -8859,7 +8662,7 @@ is-shared-array-buffer@^1.0.2: dependencies: call-bind "^1.0.2" -is-stream@^1.0.1, is-stream@^1.1.0: +is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ== @@ -9017,12 +8820,12 @@ iterator.prototype@^1.1.2: reflect.getprototypeof "^1.0.4" set-function-name "^2.0.1" -jackspeak@^2.3.5: - version "2.3.6" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8" - integrity sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ== +jackspeak@2.1.1, jackspeak@^2.3.5: + version "2.1.1" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.1.1.tgz#2a42db4cfbb7e55433c28b6f75d8b796af9669cd" + integrity sha512-juf9stUEwUaILepraGOWIJTLwg48bUnBmRqd2ln2Os1sW987zeoj/hzhbvRB95oMuS2ZTpjULmdwHNX4rzZIZw== dependencies: - "@isaacs/cliui" "^8.0.2" + cliui "^8.0.1" optionalDependencies: "@pkgjs/parseargs" "^0.11.0" @@ -9246,7 +9049,7 @@ keyv@^4.5.3: dependencies: json-buffer "3.0.1" -kind-of@^6.0.2: +kind-of@^6.0.2, kind-of@^6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== @@ -9591,6 +9394,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lru-cache@^7.5.1: + version "7.18.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" + integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== + "lru-cache@^9.1.1 || ^10.0.0": version "10.2.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3" @@ -10222,7 +10030,7 @@ minimatch@^9.0.1, minimatch@^9.0.3: dependencies: brace-expansion "^2.0.1" -minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: +minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -10417,15 +10225,7 @@ node-fetch-native@^1.6.1: resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.6.2.tgz#f439000d972eb0c8a741b65dcda412322955e1c6" integrity sha512-69mtXOFZ6hSkYiXAVB5SqaRvrbITC/NPyqv7yuu/qw0nmgPyYbIMYYNIDhNtwPrzk0ptrimrLz/hhjvm4w5Z+w== -node-fetch@^1.0.1: - version "1.7.3" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" - integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== - dependencies: - encoding "^0.1.11" - is-stream "^1.0.1" - -node-fetch@^2.0.0: +node-fetch@^1.0.1, node-fetch@^2.0.0, node-fetch@^2.6.7: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -11046,15 +10846,6 @@ postcss-load-config@^4.0.1: lilconfig "^3.0.0" yaml "^2.3.4" -postcss@^8.4.18: - version "8.4.38" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" - integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== - dependencies: - nanoid "^3.3.7" - picocolors "^1.0.0" - source-map-js "^1.2.0" - postcss@^8.4.35: version "8.4.35" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.35.tgz#60997775689ce09011edf083a549cea44aabe2f7" @@ -12074,13 +11865,6 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" -rollup@^2.79.1: - version "2.79.1" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7" - integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw== - optionalDependencies: - fsevents "~2.3.2" - rollup@^4.0.2, rollup@^4.2.0: version "4.9.6" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.9.6.tgz#4515facb0318ecca254a2ee1315e22e09efc50a0" @@ -12223,17 +12007,7 @@ semver-compare@^1.0.0: resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== -"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0: - version "5.7.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" - integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== - -semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.1: - version "6.3.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== - -semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.5.3: +"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3: version "7.6.0" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== @@ -12483,11 +12257,6 @@ source-map-js@^1.0.2: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== -source-map-js@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" - integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== - source-map-support@^0.5.16, source-map-support@^0.5.19: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" @@ -12637,15 +12406,6 @@ string-argv@0.3.2: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" @@ -12672,6 +12432,15 @@ string-width@^3.0.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.0, string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -12746,13 +12515,6 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^3.0.0, strip-ansi@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" @@ -12774,6 +12536,13 @@ strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -13378,7 +13147,7 @@ typescript@^4.9.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== -ua-parser-js@^0.7.30: +ua-parser-js@^0.7.30, ua-parser-js@^0.7.33: version "0.7.37" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.37.tgz#e464e66dac2d33a7a1251d7d7a99d6157ec27832" integrity sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA== @@ -13719,18 +13488,6 @@ vite-plugin-svgr@^3.2.0: "@svgr/core" "^8.1.0" "@svgr/plugin-jsx" "^8.1.0" -vite@^3.0.0: - version "3.2.10" - resolved "https://registry.yarnpkg.com/vite/-/vite-3.2.10.tgz#7ac79fead82cfb6b5bf65613cd82fba6dcc81340" - integrity sha512-Dx3olBo/ODNiMVk/cA5Yft9Ws+snLOXrhLtrI3F4XLt4syz2Yg8fayZMWScPKoz12v5BUv7VEmQHnsfpY80fYw== - dependencies: - esbuild "^0.15.9" - postcss "^8.4.18" - resolve "^1.22.1" - rollup "^2.79.1" - optionalDependencies: - fsevents "~2.3.2" - vite@^5.0.0, vite@^5.1.7: version "5.1.7" resolved "https://registry.yarnpkg.com/vite/-/vite-5.1.7.tgz#9f685a2c4c70707fef6d37341b0e809c366da619" @@ -13742,17 +13499,6 @@ vite@^5.0.0, vite@^5.1.7: optionalDependencies: fsevents "~2.3.3" -vitest-preview@^0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/vitest-preview/-/vitest-preview-0.0.1.tgz#ae53da961082ff20a8dec5deba8484f36b120268" - integrity sha512-rKh+rzW54HYfgYjCU/9n8t0V8rnxYiH67uJGYUKKqW5L87Cl8NESDzNe2BbD6WmNvM4ojQdc0VqLXv6QsDt1Jw== - dependencies: - "@types/express" "^4.17.14" - "@types/node" "^18.11.3" - "@vitest-preview/dev-utils" "0.0.1" - express "^4.18.2" - vite "^3.0.0" - vitest@^1.0.1, vitest@^1.2.0: version "1.2.2" resolved "https://registry.yarnpkg.com/vitest/-/vitest-1.2.2.tgz#9e29ad2a74a5df553c30c5798c57a062d58ce299" @@ -13960,7 +13706,7 @@ widest-line@^4.0.1: dependencies: string-width "^5.0.1" -word-wrap@~1.2.3: +word-wrap@^1.2.4, word-wrap@~1.2.3: version "1.2.5" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== @@ -13970,15 +13716,6 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" @@ -13996,6 +13733,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -14089,30 +13835,12 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yaml@2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b" - integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ== - -yaml@^1.10.0, yaml@^1.7.2: - version "1.10.2" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" - integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== - -yaml@^2.2.2, yaml@^2.3.4: +yaml@2.3.1, yaml@^1.10.0, yaml@^1.7.2, yaml@^2.2.2, yaml@^2.3.0, yaml@^2.3.4: version "2.4.1" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.1.tgz#2e57e0b5e995292c25c75d2658f0664765210eed" integrity sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg== -yargs-parser@^11.1.1: - version "11.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-11.1.1.tgz#879a0865973bca9f6bab5cbdf3b1c67ec7d3bcf4" - integrity sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ== - dependencies: - camelcase "^5.0.0" - decamelize "^1.2.0" - -yargs-parser@^21.1.1: +yargs-parser@^11.1.1, yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== From 1e9c3f093c0948b41d161ec1ec1a3b3b5a24c20a Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 21 May 2024 10:52:47 -0400 Subject: [PATCH 002/163] test: [M3-7991] - Integration Test: Linode Create with Placement Group (#10473) * New test * Added changeset: Integration Test: Linode Create with Placement Group * feedback @mjac0bs * feedback @mjac0bs * feedback @jdamore-linode * feedback @jdamore-linode mock linodeCreateRefactor --- .../pr-10473-tests-1715795923381.md | 5 + ...reate-linode-with-placement-groups.spec.ts | 209 ++++++++++++++++++ .../create-placement-groups.spec.ts | 8 +- .../components/TextTooltip/TextTooltip.tsx | 8 + packages/manager/src/components/Tooltip.tsx | 7 +- ...entGroupsAffinityEnforcementRadioGroup.tsx | 4 +- .../PlacementGroupsDetailPanel.tsx | 6 +- .../src/features/PlacementGroups/constants.ts | 3 + 8 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-10473-tests-1715795923381.md create mode 100644 packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts diff --git a/packages/manager/.changeset/pr-10473-tests-1715795923381.md b/packages/manager/.changeset/pr-10473-tests-1715795923381.md new file mode 100644 index 00000000000..e803729ce57 --- /dev/null +++ b/packages/manager/.changeset/pr-10473-tests-1715795923381.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Integration Test for Linode Create with Placement Group ([#10473](https://github.com/linode/manager/pull/10473)) diff --git a/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts new file mode 100644 index 00000000000..c0ad5c28d4c --- /dev/null +++ b/packages/manager/cypress/e2e/core/placementGroups/create-linode-with-placement-groups.spec.ts @@ -0,0 +1,209 @@ +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + accountFactory, + linodeFactory, + placementGroupFactory, +} from 'src/factories'; +import { regionFactory } from 'src/factories'; +import { ui } from 'support/ui/'; +import { mockCreateLinode } from 'support/intercepts/linodes'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + mockCreatePlacementGroup, + mockGetPlacementGroups, +} from 'support/intercepts/placement-groups'; +import { randomString } from 'support/util/random'; +import { CANNOT_CHANGE_AFFINITY_TYPE_ENFORCEMENT_MESSAGE } from 'src/features/PlacementGroups/constants'; + +import type { Region } from '@linode/api-v4'; +import type { Flags } from 'src/featureFlags'; + +const mockAccount = accountFactory.build(); +const mockRegions: Region[] = [ + regionFactory.build({ + capabilities: ['Linodes', 'Placement Group'], + id: 'us-east', + label: 'Newark, NJ', + country: 'us', + }), + regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-central', + label: 'Dallas, TX', + country: 'us', + }), +]; + +describe('Linode create flow with Placement Group', () => { + beforeEach(() => { + mockGetAccount(mockAccount); + mockGetRegions(mockRegions).as('getRegions'); + // TODO Remove feature flag mocks when `placementGroups` flag is retired. + mockAppendFeatureFlags({ + placementGroups: makeFeatureFlagData({ + beta: true, + enabled: true, + }), + linodeCreateRefactor: makeFeatureFlagData( + false + ), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms Placement Group create UI flow using mock API data. + * - Confirms that outgoing Placement Group create request contains expected data. + * - Confirms that Cloud automatically updates to list new Placement Group on landing page. + */ + it('can create a linode with a newly created Placement Group', () => { + cy.visitWithLogin('/linodes/create'); + + cy.findByText( + 'Select a Region for your Linode to see existing placement groups.' + ).should('be.visible'); + + // Region without capability + // Choose region + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(mockRegions[1].label).click(); + + // Choose plan + cy.findByText('Shared CPU').click(); + cy.get('[id="g6-nanode-1"]').click(); + + cy.findByText('Placement Groups in Dallas, TX (us-central)').should( + 'be.visible' + ); + cy.get('[data-testid="placement-groups-no-capability-notice"]').should( + 'be.visible' + ); + ui.tooltip + .findByText('Regions that support placement groups') + .should('be.visible') + .click(); + cy.get('[data-testid="supported-pg-region-us-east"]').should('be.visible'); + + // Region with capability + // Choose region + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(mockRegions[0].label).click(); + + // Choose plan + cy.findByText('Shared CPU').click(); + cy.get('[id="g6-nanode-1"]').click(); + + // Choose Placement Group + // No Placement Group available + cy.findByText('Placement Groups in Newark, NJ (us-east)').should( + 'be.visible' + ); + // Open the select + cy.get('[data-testid="placement-groups-select"] input').click(); + cy.findByText('There are no placement groups in this region.').click(); + // Close the select + cy.get('[data-testid="placement-groups-select"] input').click(); + + // Create a Placement Group + ui.button + .findByTitle('Create Placement Group') + .should('be.visible') + .should('be.enabled') + .click(); + + const mockPlacementGroup = placementGroupFactory.build({ + label: 'pg-1-us-east', + region: mockRegions[0].id, + affinity_type: 'anti_affinity:local', + is_strict: true, + is_compliant: true, + }); + + mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + mockCreatePlacementGroup(mockPlacementGroup).as('createPlacementGroup'); + + ui.drawer + .findByTitle('Create Placement Group') + .should('be.visible') + .within(() => { + // Confirm that the drawer contains the expected default information. + // - A selection region + // - An Affinity Type Enforcement message + // - a disabled "Create Placement Group" button. + cy.findByText('Newark, NJ (us-east)').should('be.visible'); + cy.findByText(CANNOT_CHANGE_AFFINITY_TYPE_ENFORCEMENT_MESSAGE).should( + 'be.visible' + ); + ui.buttonGroup + .findButtonByTitle('Create Placement Group') + .should('be.disabled'); + + // Enter label and submit form. + cy.findByLabelText('Label').type(mockPlacementGroup.label); + + ui.buttonGroup + .findButtonByTitle('Create Placement Group') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Wait for outgoing API request and confirm that payload contains expected data. + cy.wait('@createPlacementGroup').then((xhr) => { + const requestPayload = xhr.request?.body; + expect(requestPayload['affinity_type']).to.equal('anti_affinity:local'); + expect(requestPayload['is_strict']).to.equal(true); + expect(requestPayload['label']).to.equal(mockPlacementGroup.label); + expect(requestPayload['region']).to.equal(mockRegions[0].id); + }); + + // Confirm that the drawer closes and a success message is displayed. + ui.toast.assertMessage( + `Placement Group ${mockPlacementGroup.label} successfully created.` + ); + + // Select the newly created Placement Group. + cy.wait('@getPlacementGroups'); + cy.get('[data-testid="placement-groups-select"] input').should( + 'have.value', + mockPlacementGroup.label + ); + + const linodeLabel = 'linode-with-placement-group'; + const mockLinode = linodeFactory.build({ + label: linodeLabel, + region: mockRegions[0].id, + placement_group: { + id: mockPlacementGroup.id, + }, + }); + + // Confirm the Placement group assignment is accounted for in the summary. + cy.get('[data-qa-summary="true"]').within(() => { + cy.findByText('Assigned to Placement Group').should('be.visible'); + }); + + // Type in a label, password and submit the form. + mockCreateLinode(mockLinode).as('createLinode'); + cy.get('#linode-label').clear().type('linode-with-placement-group'); + cy.get('#root-password').type(randomString(32)); + + cy.get('[data-qa-deploy-linode]').click(); + + // Wait for outgoing API request and confirm that payload contains expected data. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request?.body; + + expect(requestPayload['region']).to.equal(mockRegions[0].id); + expect(requestPayload['label']).to.equal(linodeLabel); + expect(requestPayload['placement_group'].id).to.equal( + mockPlacementGroup.id + ); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts index 088b40ce4c1..ceb4b9669b9 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/create-placement-groups.spec.ts @@ -17,6 +17,8 @@ import { import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { CANNOT_CHANGE_AFFINITY_TYPE_ENFORCEMENT_MESSAGE } from 'src/features/PlacementGroups/constants'; + const mockAccount = accountFactory.build(); describe('Placement Group create flow', () => { @@ -69,8 +71,6 @@ describe('Placement Group create flow', () => { }); const placementGroupLimitMessage = `Maximum placement groups in region: ${mockPlacementGroupRegion.placement_group_limits.maximum_pgs_per_customer}`; - const affinityTypeMessage = - 'Once you create a placement group, you cannot change its Affinity Type Enforcement setting.'; mockGetRegions(mockRegions); mockGetPlacementGroups([]).as('getPlacementGroups'); @@ -103,7 +103,9 @@ describe('Placement Group create flow', () => { .type(`${mockPlacementGroupRegion.label}{enter}`); cy.findByText(placementGroupLimitMessage).should('be.visible'); - cy.findByText(affinityTypeMessage).should('be.visible'); + cy.findByText(CANNOT_CHANGE_AFFINITY_TYPE_ENFORCEMENT_MESSAGE).should( + 'be.visible' + ); ui.buttonGroup .findButtonByTitle('Create Placement Group') diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.tsx index d9cb98a03e2..7c49761865f 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.tsx @@ -13,6 +13,11 @@ export interface TextTooltipProps { * Props to pass to the Popper component */ PopperProps?: TooltipProps['PopperProps']; + /** + * The data-qa-tooltip attribute for the tooltip. + * Defaults to the tooltip title, but will be undefined if the title is a JSX element. + */ + dataQaTooltip?: string; /** The text to hover on to display the tooltip */ displayText: string; /** If true, the tooltip will not have a min-width of 375px @@ -41,6 +46,7 @@ export interface TextTooltipProps { export const TextTooltip = (props: TextTooltipProps) => { const { PopperProps, + dataQaTooltip, displayText, minWidth, placement, @@ -60,6 +66,8 @@ export const TextTooltip = (props: TextTooltipProps) => { }, }, }} + leaveDelay={500000} + data-qa-tooltip={dataQaTooltip} enterTouchDelay={0} placement={placement ? placement : 'bottom'} title={tooltipText} diff --git a/packages/manager/src/components/Tooltip.tsx b/packages/manager/src/components/Tooltip.tsx index c325f07cc4d..fc0fcaa273d 100644 --- a/packages/manager/src/components/Tooltip.tsx +++ b/packages/manager/src/components/Tooltip.tsx @@ -7,7 +7,12 @@ import type { TooltipProps } from '@mui/material/Tooltip'; * Tooltips display informative text when users hover over, focus on, or tap an element. */ export const Tooltip = (props: TooltipProps) => { - return <_Tooltip data-qa-tooltip={props.title} {...props} />; + // Avoiding displaying [object Object] in the data-qa-tooltip attribute when the title is an JSX element. + // Can be overridden by passing data-qa-tooltip directly to the Tooltip component. + const dataQaTooltip: string | undefined = + typeof props.title === 'string' ? props.title : undefined; + + return <_Tooltip data-qa-tooltip={dataQaTooltip} {...props} />; }; export { tooltipClasses }; export type { TooltipProps }; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityEnforcementRadioGroup.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityEnforcementRadioGroup.tsx index 6a25fe2747b..a171803e064 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityEnforcementRadioGroup.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsAffinityEnforcementRadioGroup.tsx @@ -8,6 +8,8 @@ import { Radio } from 'src/components/Radio/Radio'; import { RadioGroup } from 'src/components/RadioGroup'; import { Typography } from 'src/components/Typography'; +import { CANNOT_CHANGE_AFFINITY_TYPE_ENFORCEMENT_MESSAGE } from './constants'; + import type { FormikHelpers } from 'formik'; interface Props { @@ -29,7 +31,7 @@ export const PlacementGroupsAffinityTypeEnforcementRadioGroup = ( return ( diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx index b533dd93ab4..e9124854f1c 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetailPanel.tsx @@ -106,7 +106,10 @@ export const PlacementGroupsDetailPanel = (props: Props) => { allRegionsWithPlacementGroupCapability?.length ? ( {allRegionsWithPlacementGroupCapability?.map((region) => ( - + {region.label} ({region.id}) ))} @@ -115,6 +118,7 @@ export const PlacementGroupsDetailPanel = (props: Props) => { NO_REGIONS_SUPPORT_PLACEMENT_GROUPS_MESSAGE ) } + dataQaTooltip="Regions that support placement groups" displayText="regions" minWidth={225} />{' '} diff --git a/packages/manager/src/features/PlacementGroups/constants.ts b/packages/manager/src/features/PlacementGroups/constants.ts index 90db588e2d9..a23f7f17886 100644 --- a/packages/manager/src/features/PlacementGroups/constants.ts +++ b/packages/manager/src/features/PlacementGroups/constants.ts @@ -25,6 +25,9 @@ export const NO_PLACEMENT_GROUPS_IN_SELECTED_REGION_MESSAGE = export const NO_REGIONS_SUPPORT_PLACEMENT_GROUPS_MESSAGE = 'No regions currently support Placement Groups.'; +export const CANNOT_CHANGE_AFFINITY_TYPE_ENFORCEMENT_MESSAGE = + 'Once you create a placement group, you cannot change its Affinity Type Enforcement setting.'; + // Links export const PLACEMENT_GROUPS_DOCS_LINK = 'https://www.linode.com/docs/products/compute/compute-instances/guides/placement-groups/'; From 088379824070330475a1ebbf9ec8bdaa7cd8199b Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 21 May 2024 13:30:45 -0400 Subject: [PATCH 003/163] refactor: [M3-8045] - remove aria-label from TableRow (#10485) * remove prop and update components * Added changeset: Remove aria-label from TableRow * fix e2e tests relying on deprecated aria label * feedback @dwiley-akamai --- .../pr-10485-tech-stories-1716214544667.md | 5 +++ .../core/account/users-landing-page.spec.ts | 2 +- .../objectStorage/object-storage.e2e.spec.ts | 2 +- .../stackscripts/delete-stackscripts.spec.ts | 6 +-- .../smoke-stackscripts-landing-page.spec.ts | 2 +- .../stackscripts/update-stackscripts.spec.ts | 42 ++++++++----------- .../e2e/core/vpc/vpc-linodes-update.spec.ts | 4 +- .../CollapsibleTable/CollapsibleTable.tsx | 4 +- .../src/components/TableRow/TableRow.tsx | 10 +---- .../Databases/DatabaseLanding/DatabaseRow.tsx | 10 ++--- .../src/features/Domains/DomainTableRow.tsx | 6 +-- .../manager/src/features/Events/EventRow.tsx | 6 +-- .../Devices/FirewallDeviceRow.tsx | 5 +-- .../Firewalls/FirewallLanding/FirewallRow.tsx | 5 +-- .../ClusterList/KubernetesClusterRow.tsx | 3 +- .../NodePoolsDisplay/NodeRow.tsx | 2 +- .../LinodeFirewalls/LinodeFirewallsRow.tsx | 6 +-- .../LinodesLanding/LinodeRow/LinodeRow.tsx | 1 - .../LoadBalancerLanding/LoadBalancerRow.tsx | 5 +-- .../ActiveConnections/ConnectionRow.tsx | 2 +- .../ListeningServices/LongviewServiceRow.tsx | 2 +- .../DetailTabs/Processes/ProcessesTable.tsx | 3 +- .../DetailTabs/TopProcesses.tsx | 2 +- .../LongviewLanding/LongviewPlans.tsx | 22 +++++----- .../features/Longview/LongviewPackageRow.tsx | 2 +- .../features/Managed/Contacts/ContactsRow.tsx | 2 +- .../Managed/Credentials/CredentialRow.tsx | 1 - .../features/Managed/Monitors/MonitorRow.tsx | 3 +- .../Managed/SSHAccess/SSHAccessRow.tsx | 1 - .../NodeBalancerFirewallsRow.tsx | 6 +-- .../NodeBalancerTableRow.tsx | 2 +- .../BucketDetail/FolderTableRow.tsx | 4 +- .../BucketDetail/ObjectTableRow.tsx | 2 +- .../BucketLanding/BucketTableRow.tsx | 2 +- .../PlacementGroupsLinodesTableRow.tsx | 5 +-- .../PlacementGroupsRow.tsx | 5 +-- .../Profile/APITokens/APITokenTable.tsx | 8 +--- .../AuthenticationSettings/TrustedDevices.tsx | 2 +- .../Profile/OAuthClients/OAuthClients.tsx | 2 +- .../manager/src/features/Search/ResultRow.tsx | 2 +- .../StackScriptSelectionRow.tsx | 2 +- .../StackScriptPanel/StackScriptRow.tsx | 2 +- .../Support/SupportTickets/TicketRow.tsx | 1 - .../manager/src/features/Users/UserRow.tsx | 2 +- .../VPCs/VPCDetail/SubnetLinodeRow.tsx | 2 +- .../src/features/VPCs/VPCLanding/VPCRow.tsx | 6 +-- 46 files changed, 83 insertions(+), 138 deletions(-) create mode 100644 packages/manager/.changeset/pr-10485-tech-stories-1716214544667.md diff --git a/packages/manager/.changeset/pr-10485-tech-stories-1716214544667.md b/packages/manager/.changeset/pr-10485-tech-stories-1716214544667.md new file mode 100644 index 00000000000..290bea04284 --- /dev/null +++ b/packages/manager/.changeset/pr-10485-tech-stories-1716214544667.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Remove aria-label from TableRow ([#10485](https://github.com/linode/manager/pull/10485)) diff --git a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts index e6c79be4da4..26412953a56 100644 --- a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts @@ -114,7 +114,7 @@ describe('Users landing page', () => { // Confirm that "Child account access" column is present cy.findByText('Child Account Access').should('be.visible'); mockUsers.forEach((user) => { - cy.get(`[aria-label="User ${user.username}"]`) + cy.get(`[data-qa-table-row="${user.username}"]`) .should('be.visible') .within(() => { if ( diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index c53fdf988c3..cff27fd1f4f 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts @@ -237,7 +237,7 @@ describe('object storage end-to-end tests', () => { cy.wait('@uploadObject'); cy.reload(); - cy.findByLabelText(bucketFiles[0].name).should('be.visible'); + cy.findByText(bucketFiles[0].name).should('be.visible'); ui.button.findByTitle('Delete').should('be.visible').click(); ui.dialog diff --git a/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts index a8176d26e80..4738d91e962 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts @@ -23,7 +23,7 @@ describe('Delete stackscripts', () => { cy.wait('@getStackScripts'); // Do nothing when cancelling - cy.get(`[aria-label="${stackScripts[0].label}"]`) + cy.get(`[data-qa-table-row="${stackScripts[0].label}"]`) .closest('tr') .within(() => { ui.actionMenu @@ -47,7 +47,7 @@ describe('Delete stackscripts', () => { }); // The StackScript is deleted successfully. - cy.get(`[aria-label="${stackScripts[0].label}"]`) + cy.get(`[data-qa-table-row="${stackScripts[0].label}"]`) .closest('tr') .within(() => { ui.actionMenu @@ -73,7 +73,7 @@ describe('Delete stackscripts', () => { cy.findByText(stackScripts[0].label).should('not.exist'); // The "Automate Deployment with StackScripts!" welcome page appears when no StackScript exists. - cy.get(`[aria-label="${stackScripts[1].label}"]`) + cy.get(`[data-qa-table-row="${stackScripts[1].label}"]`) .closest('tr') .within(() => { ui.actionMenu diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts index 2727f612e2c..dd039c7bb1d 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts @@ -36,7 +36,7 @@ describe('Display stackscripts', () => { cy.wait('@getStackScripts'); stackScripts.forEach((stackScript) => { - cy.get(`[aria-label="${stackScript.label}"]`) + cy.get(`[data-qa-table-row="${stackScript.label}"]`) .closest('tr') .within(() => { cy.findByText(stackScript.deployments_total).should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts index 32144f33cab..7e52b8c6362 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts @@ -95,14 +95,12 @@ describe('Update stackscripts', () => { cy.visitWithLogin('/stackscripts/account'); cy.wait('@getStackScripts'); - cy.get(`[aria-label="${stackScripts[0].label}"]`) - .closest('tr') - .within(() => { - ui.actionMenu - .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) - .should('be.visible') - .click(); - }); + cy.get(`[data-qa-table-row="${stackScripts[0].label}"]`).within(() => { + ui.actionMenu + .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) + .should('be.visible') + .click(); + }); mockGetStackScript(stackScripts[0].id, stackScripts[0]).as( 'getStackScript' ); @@ -205,14 +203,12 @@ describe('Update stackscripts', () => { cy.wait('@getStackScripts'); // Do nothing when cancelling - cy.get(`[aria-label="${stackScripts[0].label}"]`) - .closest('tr') - .within(() => { - ui.actionMenu - .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) - .should('be.visible') - .click(); - }); + cy.get(`[data-qa-table-row="${stackScripts[0].label}"]`).within(() => { + ui.actionMenu + .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) + .should('be.visible') + .click(); + }); ui.actionMenuItem .findByTitle('Make StackScript Public') .should('be.visible') @@ -234,14 +230,12 @@ describe('Update stackscripts', () => { }); // The status of the StackScript will become public - cy.get(`[aria-label="${stackScripts[0].label}"]`) - .closest('tr') - .within(() => { - ui.actionMenu - .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) - .should('be.visible') - .click(); - }); + cy.get(`[data-qa-table-row="${stackScripts[0].label}"]`).within(() => { + ui.actionMenu + .findByTitle(`Action menu for StackScript ${stackScripts[0].label}`) + .should('be.visible') + .click(); + }); ui.actionMenuItem .findByTitle('Make StackScript Public') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts index cf132d44e2c..1c030b31225 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts @@ -192,8 +192,8 @@ describe('VPC assign/unassign flows', () => { .click(); }); - cy.get('[aria-label="View Details"]') - .closest('tbody') + cy.get('[data-qa-table-row="collapsible-table-headers-row"]') + .siblings('tbody') .within(() => { // after assigning Linode(s) to a VPC, VPC page increases number in 'Linodes' column cy.findByText('1').should('be.visible'); diff --git a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx index e266cbb1e9c..0fe1a57cbae 100644 --- a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx +++ b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx @@ -27,7 +27,9 @@ export const CollapsibleTable = (props: Props) => { return ( - {TableRowHead} + + {TableRowHead} + {TableItems.length === 0 && TableRowEmpty} {TableItems.map((item) => { diff --git a/packages/manager/src/components/TableRow/TableRow.tsx b/packages/manager/src/components/TableRow/TableRow.tsx index 4526f615662..d2ac1b07344 100644 --- a/packages/manager/src/components/TableRow/TableRow.tsx +++ b/packages/manager/src/components/TableRow/TableRow.tsx @@ -6,7 +6,6 @@ import { Hidden } from 'src/components/Hidden'; import { StyledTableDataCell, StyledTableRow } from './TableRow.styles'; export interface TableRowProps extends _TableRowProps { - ariaLabel?: string; className?: string; disabled?: boolean; domRef?: any; @@ -18,15 +17,10 @@ export interface TableRowProps extends _TableRowProps { } export const TableRow = React.memo((props: TableRowProps) => { - const { ariaLabel, domRef, selected, ...rest } = props; + const { domRef, selected, ...rest } = props; return ( - + {props.children} {selected && ( diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx index 61f053610ae..7ce00cb3ed6 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx @@ -26,7 +26,7 @@ export const databaseEngineMap: Record = { }; interface Props { - database: DatabaseInstance | Database; + database: Database | DatabaseInstance; events?: Event[]; } @@ -62,16 +62,12 @@ export const DatabaseRow = ({ database, events }: Props) => { ); return ( - + {label} - + {configuration} diff --git a/packages/manager/src/features/Domains/DomainTableRow.tsx b/packages/manager/src/features/Domains/DomainTableRow.tsx index 74968187afd..4d87c9c8380 100644 --- a/packages/manager/src/features/Domains/DomainTableRow.tsx +++ b/packages/manager/src/features/Domains/DomainTableRow.tsx @@ -21,11 +21,7 @@ export const DomainTableRow = React.memo((props: DomainTableRowProps) => { const { domain, onClone, onDisableOrEnable, onEdit, onRemove } = props; return ( - + {domain.type !== 'slave' ? ( diff --git a/packages/manager/src/features/Events/EventRow.tsx b/packages/manager/src/features/Events/EventRow.tsx index 804af2d74c1..cc403c8cb29 100644 --- a/packages/manager/src/features/Events/EventRow.tsx +++ b/packages/manager/src/features/Events/EventRow.tsx @@ -68,11 +68,7 @@ export const Row = (props: RowProps) => { } return ( - + diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx index fe17defa93e..7b1cfcb8fc6 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx @@ -14,10 +14,7 @@ export const FirewallDeviceRow = React.memo( const { deviceEntityID, deviceID, deviceLabel, deviceType } = props; return ( - + { const count = getCountOfRules(rules); return ( - + {label} diff --git a/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.tsx b/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.tsx index 8f3c3177a3d..966a9d37aac 100644 --- a/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.tsx +++ b/packages/manager/src/features/Kubernetes/ClusterList/KubernetesClusterRow.tsx @@ -1,8 +1,8 @@ import { KubeNodePoolResponse, KubernetesCluster } from '@linode/api-v4'; import Grid from '@mui/material/Unstable_Grid2'; -import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; import { Link } from 'react-router-dom'; +import { makeStyles } from 'tss-react/mui'; import { Chip } from 'src/components/Chip'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; @@ -83,7 +83,6 @@ export const KubernetesClusterRow = (props: Props) => { return ( { const displayIP = ip ?? ''; return ( - + diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx index 301a71df7ea..ea130e2c723 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx @@ -37,11 +37,7 @@ export const LinodeFirewallsRow = (props: LinodeFirewallsRowProps) => { const count = getCountOfRules(rules); return ( - + {label} diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx index 42dbda3b996..4d7a9bfce06 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx @@ -93,7 +93,6 @@ export const LinodeRow = (props: Props) => { return ( { const { hostname, id, label, regions } = loadBalancer; return ( - + {label} diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ConnectionRow.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ConnectionRow.tsx index f439417fab5..b4e99046a7e 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ConnectionRow.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ConnectionRow.tsx @@ -12,7 +12,7 @@ export const ConnectionRow = (props: Props) => { const { connection } = props; return ( - + {connection.name} diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/LongviewServiceRow.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/LongviewServiceRow.tsx index c1c2533894d..001531b60f3 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/LongviewServiceRow.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/LongviewServiceRow.tsx @@ -12,7 +12,7 @@ export const LongviewServiceRow = (props: Props) => { const { service } = props; return ( - + {service.name} diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx index 9848ed51c23..4089557afda 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesTable.tsx @@ -13,8 +13,8 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { formatCPU } from 'src/features/Longview/shared/formatters'; import { useWindowDimensions } from 'src/hooks/useWindowDimensions'; import { readableBytes } from 'src/utilities/unitConversions'; -import { StyledDiv, StyledTable } from './ProcessesTable.styles'; +import { StyledDiv, StyledTable } from './ProcessesTable.styles'; import { Process } from './types'; export interface ProcessesTableProps { @@ -176,7 +176,6 @@ export const ProcessesTableRow = React.memo((props: ProcessTableRowProps) => { onKeyUp={(e: any) => e.key === 'Enter' && setSelectedProcess({ name, user }) } - ariaLabel={`${name} for ${user}`} data-testid="longview-service-row" forceIndex onClick={() => setSelectedProcess({ name, user })} diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx index 8ddf038b9cf..d34a2585c31 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx @@ -151,7 +151,7 @@ export const TopProcessRow = React.memo((props: TopProcessRowProps) => { const memInBytes = mem * 1024; return ( - + {name} diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewPlans.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewPlans.tsx index cb458570101..6a1d777ae9b 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewPlans.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewPlans.tsx @@ -24,6 +24,7 @@ import { UseAPIRequest } from 'src/hooks/useAPIRequest'; import { useAccountSettings } from 'src/queries/account/settings'; import { useGrants, useProfile } from 'src/queries/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + import { StyledChip, StyledClientCell, @@ -228,15 +229,15 @@ export const LongviewPlans = (props: LongviewPlansProps) => { @@ -355,25 +356,24 @@ export const LongviewSubscriptionRow = React.memo( return ( {plan} diff --git a/packages/manager/src/features/Longview/LongviewPackageRow.tsx b/packages/manager/src/features/Longview/LongviewPackageRow.tsx index 97923ff4f06..15d96efb7ee 100644 --- a/packages/manager/src/features/Longview/LongviewPackageRow.tsx +++ b/packages/manager/src/features/Longview/LongviewPackageRow.tsx @@ -16,7 +16,7 @@ export const LongviewPackageRow = (props: Props) => { const theme = useTheme(); return ( - + {lvPackage.name}
{lvPackage.current}
diff --git a/packages/manager/src/features/Managed/Contacts/ContactsRow.tsx b/packages/manager/src/features/Managed/Contacts/ContactsRow.tsx index 78ecc931b69..a92b68ffe5e 100644 --- a/packages/manager/src/features/Managed/Contacts/ContactsRow.tsx +++ b/packages/manager/src/features/Managed/Contacts/ContactsRow.tsx @@ -17,7 +17,7 @@ export const ContactsRow = (props: ContactsRowProps) => { const { contact, openDialog, openDrawer } = props; return ( - + {contact.name} {contact.group} diff --git a/packages/manager/src/features/Managed/Credentials/CredentialRow.tsx b/packages/manager/src/features/Managed/Credentials/CredentialRow.tsx index 06ebbcef2c8..87bd3ebf6a9 100644 --- a/packages/manager/src/features/Managed/Credentials/CredentialRow.tsx +++ b/packages/manager/src/features/Managed/Credentials/CredentialRow.tsx @@ -19,7 +19,6 @@ export const CredentialRow = (props: CredentialRowProps) => { return ( { return ( { return ( { const count = getCountOfRules(rules); return ( - + {label} diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx index e67572e3fa4..e9a20f8274a 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.tsx @@ -30,7 +30,7 @@ export const NodeBalancerTableRow = (props: Props) => { 0; return ( - + {label} diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.tsx index e3b3dcafeff..f37af952753 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/FolderTableRow.tsx @@ -1,5 +1,5 @@ -import Grid from '@mui/material/Unstable_Grid2'; import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { Link } from 'react-router-dom'; @@ -21,7 +21,7 @@ export const FolderTableRow = (props: Props) => { const { displayName, folderName, handleClickDelete } = props; return ( - + diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx index ea11d2f4370..96d32373fd1 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectTableRow.tsx @@ -36,7 +36,7 @@ export const ObjectTableRow = (props: Props) => { } = props; return ( - + diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx index a8cf7a76563..48a52341fd7 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx @@ -63,7 +63,7 @@ export const BucketTableRow = (props: BucketTableRowProps) => { const regionsLookup = regions && getRegionsByRegionId(regions); return ( - + diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx index 196308c156d..9c8b4d1cbe3 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodesTableRow.tsx @@ -28,10 +28,7 @@ export const PlacementGroupsLinodesTableRow = React.memo((props: Props) => { }); return ( - + {label} diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx index 900dc4a0906..2664c099451 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsRow.tsx @@ -55,10 +55,7 @@ export const PlacementGroupsRow = React.memo( ]; return ( - + { const renderRows = (tokens: Token[]) => { return tokens.map((token: Token) => ( - + {token.label} diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx index ef75958b641..1018568b59f 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx @@ -86,7 +86,7 @@ const TrustedDevices = () => { return data?.data.map((device) => { return ( - + {device.user_agent} {device.last_remote_addr} diff --git a/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx b/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx index b82ba542499..974e8726b88 100644 --- a/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/OAuthClients.tsx @@ -90,7 +90,7 @@ const OAuthClients = () => { } return data?.data.map(({ id, label, public: isPublic, redirect_uri }) => ( - + {label} {isPublic ? 'Public' : 'Private'} diff --git a/packages/manager/src/features/Search/ResultRow.tsx b/packages/manager/src/features/Search/ResultRow.tsx index d06ea4efd0d..0fcb85abc83 100644 --- a/packages/manager/src/features/Search/ResultRow.tsx +++ b/packages/manager/src/features/Search/ResultRow.tsx @@ -24,7 +24,7 @@ export const ResultRow = (props: ResultRowProps) => { const { result } = props; return ( - + {result.label} diff --git a/packages/manager/src/features/StackScripts/SelectStackScriptPanel/StackScriptSelectionRow.tsx b/packages/manager/src/features/StackScripts/SelectStackScriptPanel/StackScriptSelectionRow.tsx index 0bfa67c876d..8d253c45ca3 100644 --- a/packages/manager/src/features/StackScripts/SelectStackScriptPanel/StackScriptSelectionRow.tsx +++ b/packages/manager/src/features/StackScripts/SelectStackScriptPanel/StackScriptSelectionRow.tsx @@ -93,7 +93,7 @@ export class StackScriptSelectionRow extends React.Component< }; return ( - + { }; return ( - + {renderLabel()} diff --git a/packages/manager/src/features/Support/SupportTickets/TicketRow.tsx b/packages/manager/src/features/Support/SupportTickets/TicketRow.tsx index 276c52bf31f..031f71c2f19 100644 --- a/packages/manager/src/features/Support/SupportTickets/TicketRow.tsx +++ b/packages/manager/src/features/Support/SupportTickets/TicketRow.tsx @@ -45,7 +45,6 @@ export const TicketRow = ({ ticket }: Props) => { return ( { flags.parentChildAccountAccess && profile?.user_type === 'parent'; return ( - + diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx index 0df5ff9bd16..9539d229c32 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx @@ -1,8 +1,8 @@ import { APIError, Firewall, Linode } from '@linode/api-v4'; import { Config, Interface } from '@linode/api-v4/lib/linodes/types'; import ErrorOutline from '@mui/icons-material/ErrorOutline'; -import * as React from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import * as React from 'react'; import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress'; diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx index 690e6785fd9..cbdcf2496fd 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCRow.tsx @@ -37,11 +37,7 @@ export const VPCRow = ({ handleDeleteVPC, handleEditVPC, vpc }: Props) => { ]; return ( - + {label} From b64491f3eeb076a31e0e6090eb92b25fabd961d7 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 21 May 2024 13:32:23 -0400 Subject: [PATCH 004/163] feat: [M3-8069] - scrollErrorIntoViewV2 - POC (#10459) * Initial commit: new util * Cleanup and test * Feedback @mjac0bs * feedback @mjac0bs: add new v2 util to user permissions * feedback @mjac0bs: documentation * Update docs/development-guide/05-fetching-data.md Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * Improvements and couple more examples * Improve docs --------- Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- docs/development-guide/05-fetching-data.md | 98 +++++++++++ .../DatabaseCreate/DatabaseCreate.tsx | 16 +- .../Linodes/LinodesCreate/LinodeCreate.tsx | 18 +- .../LinodesCreate/LinodeCreateContainer.tsx | 162 +++++++----------- .../LinodeConfigs/LinodeConfigDialog.tsx | 11 +- .../src/features/Users/UserPermissions.tsx | 16 +- .../src/utilities/scrollErrorIntoView.ts | 4 + .../utilities/scrollErrorIntoViewV2.test.tsx | 45 +++++ .../src/utilities/scrollErrorIntoViewV2.ts | 47 +++++ 9 files changed, 298 insertions(+), 119 deletions(-) create mode 100644 packages/manager/src/utilities/scrollErrorIntoViewV2.test.tsx create mode 100644 packages/manager/src/utilities/scrollErrorIntoViewV2.ts diff --git a/docs/development-guide/05-fetching-data.md b/docs/development-guide/05-fetching-data.md index f5780d41405..e1313ed83c4 100644 --- a/docs/development-guide/05-fetching-data.md +++ b/docs/development-guide/05-fetching-data.md @@ -244,6 +244,104 @@ console.log(errorMap); } ``` +#### Scrolling to errors + +For deep forms, we provide a utility that will scroll to the first error encountered within a defined container. We do this to improve error visibility, because the user can be unaware of an error that isn't in the viewport. +An error can be a notice (API error) or a Formik field error. In order to implement this often needed functionality, we must declare a form or form container via ref, then pass it to the `scrollErrorIntoViewV2` util (works both for class & functional components). + +Note: the legacy `scrollErrorIntoView` is deprecated in favor of `scrollErrorIntoViewV2`. + +Since Cloud Manager uses different ways of handling forms and validation, the `scrollErrorIntoViewV2` util should be implemented using the following patterns to ensure consistency. + +##### Formik +```Typescript +import * as React from 'react'; + +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; + +export const MyComponent = () => { + const formContainerRef = React.useRef(null); + + const { + values, + // other handlers + } = useFormik({ + initialValues: {}, + onSubmit: mySubmitFormHandler, + validate: () => { + scrollErrorIntoViewV2(formRef); + }, + validationSchema: myValidationSchema, + }); + + return ( +
+ + {/* form fields */} + + + ); +}; +``` + +##### React Hook Forms +```Typescript +import * as React from 'react'; + +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; + +export const MyComponent = () => { + const formContainerRef = React.useRef(null); + + const methods = useForm({ + defaultValues, + mode: 'onBlur', + resolver: myResolvers, + // other methods + }); + + return ( + +
scrollErrorIntoViewV2(formRef))} + ref={formContainerRef} + > + + {/* form fields */} + + + + ); +}; +``` + +##### Uncontrolled forms +```Typescript +import * as React from 'react'; + +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; + +export const MyComponent = () => { + const formContainerRef = React.useRef(null); + + const handleSubmit = () => { + try { + // form submission logic + } catch { + scrollErrorIntoViewV2(formContainerRef); + } + }; + + return ( +
+ + {/* form fields */} + + + ); +}; +``` + ### Toast / Event Message Punctuation **Best practice:** - If a message is a sentence or a sentence fragment with a subject and a verb, add punctuation. Otherwise, leave punctuation off. diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx index 5711690317d..fdfd26c8145 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -60,7 +60,7 @@ import { ipFieldPlaceholder, validateIPs, } from 'src/utilities/ipUtils'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; import type { PlanSelectionType } from 'src/features/components/PlansPanel/types'; @@ -213,6 +213,7 @@ const DatabaseCreate = () => { isLoading: typesLoading, } = useDatabaseTypesQuery(); + const formRef = React.useRef(null); const { mutateAsync: createDatabase } = useCreateDatabaseMutation(); const [nodePricing, setNodePricing] = React.useState(); @@ -316,7 +317,10 @@ const DatabaseCreate = () => { type: '', }, onSubmit: submitForm, - validate: handleIPValidation, + validate: () => { + handleIPValidation(); + scrollErrorIntoViewV2(formRef); + }, validateOnChange: false, validationSchema: createDatabaseSchema, }); @@ -351,12 +355,6 @@ const DatabaseCreate = () => { }); }, [dbtypes, selectedEngine]); - React.useEffect(() => { - if (errors || createError) { - scrollErrorIntoView(); - } - }, [errors, createError]); - const labelToolTip = (
Label must: @@ -444,7 +442,7 @@ const DatabaseCreate = () => { } return ( -
+ + {hasErrorFor.none && !!showGeneralError && ( @@ -855,6 +859,16 @@ export class LinodeCreate extends React.PureComponent< const { selectedTab } = this.state; const selectedTabName = this.tabs[selectedTab].title as LinodeCreateType; + try { + CreateLinodeSchema.validateSync(payload, { + abortEarly: true, + }); + this.setState({ hasError: false }); + } catch (e) { + this.setState({ hasError: true }, () => { + scrollErrorIntoViewV2(this.createLinodeFormRef); + }); + } this.props.handleSubmitForm(payload, this.props.selectedLinodeID); sendLinodeCreateFormSubmitEvent( 'Create Linode', @@ -863,6 +877,8 @@ export class LinodeCreate extends React.PureComponent< ); }; + createLinodeFormRef = React.createRef(); + filterTypes = () => { const { createType, typesData } = this.props; const { selectedTab } = this.state; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx index b83d8c280be..1665e52ac27 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreateContainer.tsx @@ -72,7 +72,6 @@ import { ExtendedIP } from 'src/utilities/ipUtils'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { validatePassword } from 'src/utilities/validatePassword'; import { deriveDefaultLabel } from './deriveDefaultLabel'; @@ -350,13 +349,10 @@ class LinodeCreateContainer extends React.PureComponent { this.setState({ errors: undefined, showApiAwarenessModal: true }); } catch (error) { const processedErrors = convertYupToLinodeErrors(error); - this.setState( - () => ({ - errors: getAPIErrorOrDefault(processedErrors), - formIsSubmitting: false, - }), - () => scrollErrorIntoView() - ); + this.setState(() => ({ + errors: getAPIErrorOrDefault(processedErrors), + formIsSubmitting: false, + })); } }; @@ -738,19 +734,14 @@ class LinodeCreateContainer extends React.PureComponent { if (payload.root_pass) { const passwordError = validatePassword(payload.root_pass); if (passwordError) { - this.setState( - { - errors: [ - { - field: 'root_pass', - reason: passwordError, - }, - ], - }, - () => { - scrollErrorIntoView(); - } - ); + this.setState({ + errors: [ + { + field: 'root_pass', + reason: passwordError, + }, + ], + }); return; } } @@ -763,24 +754,19 @@ class LinodeCreateContainer extends React.PureComponent { )!, }); if (error) { - this.setState( - { - errors: [ - { - field: 'placement_group', - reason: `${this.state.placementGroupSelection?.label} (${ - this.state.placementGroupSelection?.affinity_type === - 'affinity:local' - ? 'Affinity' - : 'Anti-affinity' - }) doesn't have any capacity for this Linode.`, - }, - ], - }, - () => { - scrollErrorIntoView(); - } - ); + this.setState({ + errors: [ + { + field: 'placement_group', + reason: `${this.state.placementGroupSelection?.label} (${ + this.state.placementGroupSelection?.affinity_type === + 'affinity:local' + ? 'Affinity' + : 'Anti-affinity' + }) doesn't have any capacity for this Linode.`, + }, + ], + }); return; } } @@ -799,17 +785,14 @@ class LinodeCreateContainer extends React.PureComponent { // Situation: 'Auto-assign a VPC IPv4 address for this Linode in the VPC' checkbox // unchecked but a valid VPC IPv4 not provided if (!this.state.autoassignIPv4WithinVPCEnabled && !validVPCIPv4) { - return this.setState( - () => ({ - errors: [ - { - field: 'ipv4.vpc', - reason: 'Must be a valid IPv4 address, e.g. 192.168.2.0', - }, - ], - }), - () => scrollErrorIntoView() - ); + return this.setState(() => ({ + errors: [ + { + field: 'ipv4.vpc', + reason: 'Must be a valid IPv4 address, e.g. 192.168.2.0', + }, + ], + })); } } @@ -819,58 +802,44 @@ class LinodeCreateContainer extends React.PureComponent { * if create, run create action */ if (createType === 'fromLinode' && !linodeID) { - return this.setState( - () => ({ - errors: [ - { - field: 'linode_id', - reason: 'You must select a Linode to clone from', - }, - ], - }), - () => scrollErrorIntoView() - ); + return this.setState(() => ({ + errors: [ + { + field: 'linode_id', + reason: 'You must select a Linode to clone from', + }, + ], + })); } if (createType === 'fromBackup' && !this.state.selectedBackupID) { /* a backup selection is also required */ - this.setState( - { - errors: [{ field: 'backup_id', reason: 'You must select a Backup.' }], - }, - () => { - scrollErrorIntoView(); - } - ); + this.setState({ + errors: [{ field: 'backup_id', reason: 'You must select a Backup.' }], + }); return; } if (createType === 'fromStackScript' && !this.state.selectedStackScriptID) { - return this.setState( - () => ({ - errors: [ - { - field: 'stackscript_id', - reason: 'You must select a StackScript.', - }, - ], - }), - () => scrollErrorIntoView() - ); + return this.setState(() => ({ + errors: [ + { + field: 'stackscript_id', + reason: 'You must select a StackScript.', + }, + ], + })); } if (createType === 'fromApp' && !this.state.selectedStackScriptID) { - return this.setState( - () => ({ - errors: [ - { - field: 'stackscript_id', - reason: 'You must select a Marketplace App.', - }, - ], - }), - () => scrollErrorIntoView() - ); + return this.setState(() => ({ + errors: [ + { + field: 'stackscript_id', + reason: 'You must select a Marketplace App.', + }, + ], + })); } const request = @@ -938,13 +907,10 @@ class LinodeCreateContainer extends React.PureComponent { this.props.history.push(`/linodes/${response.id}`); }) .catch((error) => { - this.setState( - () => ({ - errors: getAPIErrorOrDefault(error), - formIsSubmitting: false, - }), - () => scrollErrorIntoView() - ); + this.setState(() => ({ + errors: getAPIErrorOrDefault(error), + formIsSubmitting: false, + })); }); }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx index 800f43a202e..a0ebfee8d16 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx @@ -62,7 +62,7 @@ import { } from 'src/utilities/formikErrorUtils'; import { getSelectedOptionFromGroupedOptions } from 'src/utilities/getSelectedOptionFromGroupedOptions'; import { ExtendedIP } from 'src/utilities/ipUtils'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; import { ExtendedInterface, @@ -245,6 +245,7 @@ const deviceCounterDefault = 1; const finnixDiskID = 25669; export const LinodeConfigDialog = (props: Props) => { + const formContainerRef = React.useRef(null); const { config, isReadOnly, linodeId, onClose, open } = props; const { data: linode } = useLinodeQuery(linodeId, open); @@ -304,7 +305,10 @@ export const LinodeConfigDialog = (props: Props) => { const { resetForm, setFieldValue, values, ...formik } = useFormik({ initialValues: defaultFieldsValues, onSubmit: (values) => onSubmit(values), - validate: (values) => onValidate(values), + validate: (values) => { + onValidate(values); + scrollErrorIntoViewV2(formContainerRef); + }, validateOnChange: false, validateOnMount: false, }); @@ -449,7 +453,6 @@ export const LinodeConfigDialog = (props: Props) => { error, 'An unexpected error occurred.' ); - scrollErrorIntoView('linode-config-dialog'); }; /** Editing */ @@ -687,7 +690,7 @@ export const LinodeConfigDialog = (props: Props) => { open={open} title={`${config ? 'Edit' : 'Add'} Configuration`} > - + {generalError && ( diff --git a/packages/manager/src/features/Users/UserPermissions.tsx b/packages/manager/src/features/Users/UserPermissions.tsx index ad12a3eb673..c32ad5c8ab9 100644 --- a/packages/manager/src/features/Users/UserPermissions.tsx +++ b/packages/manager/src/features/Users/UserPermissions.tsx @@ -46,7 +46,7 @@ import { PARENT_USER, grantTypeMap } from 'src/features/Account/constants'; import { accountQueries } from 'src/queries/account/queries'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; import { StyledCircleProgress, @@ -109,10 +109,10 @@ class UserPermissions extends React.Component { const { currentUsername } = this.props; return ( - +
{loading ? : this.renderBody()} - +
); } @@ -184,6 +184,8 @@ class UserPermissions extends React.Component { } }; + formContainerRef = React.createRef(); + getTabInformation = (grants: Grants) => this.entityPerms.reduce( (acc: TabInfo, entity: GrantType) => { @@ -232,7 +234,7 @@ class UserPermissions extends React.Component { 'Unknown error occurred while fetching user permissions. Try again later.' ), }); - scrollErrorIntoView(); + scrollErrorIntoViewV2(this.formContainerRef); }); } }; @@ -255,7 +257,7 @@ class UserPermissions extends React.Component { 'Unknown error occurred while fetching user permissions. Try again later.' ), }); - scrollErrorIntoView(); + scrollErrorIntoViewV2(this.formContainerRef); } } }; @@ -736,7 +738,7 @@ class UserPermissions extends React.Component { ), isSavingGlobal: false, }); - scrollErrorIntoView(); + scrollErrorIntoViewV2(this.formContainerRef); }); } @@ -794,7 +796,7 @@ class UserPermissions extends React.Component { ), isSavingEntity: false, }); - scrollErrorIntoView(); + scrollErrorIntoViewV2(this.formContainerRef); }); }; diff --git a/packages/manager/src/utilities/scrollErrorIntoView.ts b/packages/manager/src/utilities/scrollErrorIntoView.ts index dc4bd139771..3ea821ac59e 100644 --- a/packages/manager/src/utilities/scrollErrorIntoView.ts +++ b/packages/manager/src/utilities/scrollErrorIntoView.ts @@ -1,3 +1,7 @@ +/** + * @deprecated + * Use `scrollErrorIntoViewV2` instead. + */ export const scrollErrorIntoView = ( errorGroup?: string, options?: ScrollIntoViewOptions diff --git a/packages/manager/src/utilities/scrollErrorIntoViewV2.test.tsx b/packages/manager/src/utilities/scrollErrorIntoViewV2.test.tsx new file mode 100644 index 00000000000..2797d737b59 --- /dev/null +++ b/packages/manager/src/utilities/scrollErrorIntoViewV2.test.tsx @@ -0,0 +1,45 @@ +import { scrollErrorIntoViewV2 } from './scrollErrorIntoViewV2'; + +import type { Mock } from 'vitest'; + +describe('scrollErrorIntoViewV2', () => { + it('should scroll to the error element when it exists', () => { + window.HTMLElement.prototype.scrollIntoView = vi.fn(); + + const errorElement = document.createElement('div'); + errorElement.classList.add('error-for-scroll'); + const formContainer = document.createElement('div'); + formContainer.appendChild(errorElement); + + const formContainerRef = { + current: formContainer, + }; + + const observeMock = vi.fn(); + const disconnectMock = vi.fn(); + const takeRecords = vi.fn(); + window.MutationObserver = vi.fn(() => ({ + disconnect: disconnectMock, + observe: observeMock, + takeRecords, + })); + + scrollErrorIntoViewV2(formContainerRef); + + expect(observeMock).toHaveBeenCalledWith(formContainer, { + attributes: true, + childList: true, + subtree: true, + }); + + const mutationCallback = (window.MutationObserver as Mock).mock.calls[0][0]; + mutationCallback([{ target: formContainer, type: 'childList' }]); + + expect(errorElement.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + inline: 'nearest', + }); + expect(disconnectMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/utilities/scrollErrorIntoViewV2.ts b/packages/manager/src/utilities/scrollErrorIntoViewV2.ts new file mode 100644 index 00000000000..25c3250f041 --- /dev/null +++ b/packages/manager/src/utilities/scrollErrorIntoViewV2.ts @@ -0,0 +1,47 @@ +/** + * This utility is the version 2 of the scrollErrorIntoView utility. + * It should be the preferred utility in formik forms. + * It uses a MutationObserver to solve the issue of the form not always being + * fully rendered when the scrollErrorIntoView function is called, resulting in + * some instances in the error not being scrolled into view. + * + * If there are multiple form errors, the first one will be scrolled into view. + * + * @param formContainerRef A React ref to the form element (or a form container since we're not always semantically aligned on form markup) that contains a potential field error. + */ +export const scrollErrorIntoViewV2 = ( + formContainerRef: React.RefObject +) => { + if (!formContainerRef.current) { + return; + } + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if ( + (mutation.type === 'childList' || mutation.type === 'attributes') && + formContainerRef.current + ) { + const errorElement = formContainerRef.current.querySelector( + '[class*="error-for-scroll"]' + ); + if (errorElement) { + errorElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'nearest', + }); + observer.disconnect(); + } + } + }); + }); + + observer.observe(formContainerRef.current, { + attributes: true, + childList: true, + subtree: true, + }); + + return () => observer.disconnect(); +}; From 2579ad8220f967abbd97ef50f93f8e26d30301b9 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Tue, 21 May 2024 13:59:57 -0400 Subject: [PATCH 005/163] =?UTF-8?q?upcoming:=20[M3-8074]=20=E2=80=93=20Add?= =?UTF-8?q?=20"Disk=20Encryption"=20section=20to=20Linode=20Create=20flow?= =?UTF-8?q?=20(#10462)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pr-10462-changed-1715896838291.md | 5 ++ packages/api-v4/src/account/types.ts | 1 + packages/api-v4/src/regions/types.ts | 1 + .../pr-10462-tests-1716303824484.md | 5 ++ ...r-10462-upcoming-features-1715896941491.md | 5 ++ .../e2e/core/linodes/create-linode.spec.ts | 76 +++++++++++++++++++ .../components/AccessPanel/AccessPanel.tsx | 61 +++++++++++++++ .../DiskEncryption/DiskEncryption.test.tsx | 18 ++++- .../DiskEncryption/DiskEncryption.tsx | 18 +++-- .../components/DiskEncryption/constants.tsx | 3 + .../src/components/DiskEncryption/utils.ts | 31 ++++++++ packages/manager/src/factories/account.ts | 11 +-- .../LinodesCreate/AddonsPanel.test.tsx | 1 + .../Linodes/LinodesCreate/AddonsPanel.tsx | 30 +++++++- .../Linodes/LinodesCreate/LinodeCreate.tsx | 59 ++++++++++++++ .../LinodesCreate/LinodeCreateContainer.tsx | 8 ++ .../pr-10462-changed-1715896899402.md | 5 ++ packages/validation/src/linodes.schema.ts | 10 +-- 18 files changed, 324 insertions(+), 24 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10462-changed-1715896838291.md create mode 100644 packages/manager/.changeset/pr-10462-tests-1716303824484.md create mode 100644 packages/manager/.changeset/pr-10462-upcoming-features-1715896941491.md create mode 100644 packages/manager/src/components/DiskEncryption/utils.ts create mode 100644 packages/validation/.changeset/pr-10462-changed-1715896899402.md diff --git a/packages/api-v4/.changeset/pr-10462-changed-1715896838291.md b/packages/api-v4/.changeset/pr-10462-changed-1715896838291.md new file mode 100644 index 00000000000..2f5298f117d --- /dev/null +++ b/packages/api-v4/.changeset/pr-10462-changed-1715896838291.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Add Disk Encryption to AccountCapability type and region Capabilities type ([#10462](https://github.com/linode/manager/pull/10462)) diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 7f2aecd62b1..08b89d605e8 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -63,6 +63,7 @@ export type AccountCapability = | 'Akamai Cloud Load Balancer' | 'Block Storage' | 'Cloud Firewall' + | 'Disk Encryption' | 'Kubernetes' | 'Linodes' | 'LKE HA Control Planes' diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index 01a7bd31e64..30d6e03826c 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -5,6 +5,7 @@ export type Capabilities = | 'Block Storage' | 'Block Storage Migrations' | 'Cloud Firewall' + | 'Disk Encryption' | 'GPU Linodes' | 'Kubernetes' | 'Linodes' diff --git a/packages/manager/.changeset/pr-10462-tests-1716303824484.md b/packages/manager/.changeset/pr-10462-tests-1716303824484.md new file mode 100644 index 00000000000..8a4005759be --- /dev/null +++ b/packages/manager/.changeset/pr-10462-tests-1716303824484.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress test coverage for Disk Encryption in Linode Create flow ([#10462](https://github.com/linode/manager/pull/10462)) diff --git a/packages/manager/.changeset/pr-10462-upcoming-features-1715896941491.md b/packages/manager/.changeset/pr-10462-upcoming-features-1715896941491.md new file mode 100644 index 00000000000..09bbf5b63a3 --- /dev/null +++ b/packages/manager/.changeset/pr-10462-upcoming-features-1715896941491.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Disk Encryption section to Linode Create flow ([#10462](https://github.com/linode/manager/pull/10462)) diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index a7d7a8c2fb9..025a447e577 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -19,6 +19,7 @@ import { VLANFactory, LinodeConfigInterfaceFactory, LinodeConfigInterfaceFactoryWithVPC, + accountFactory, } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { cleanUp } from 'support/util/cleanup'; @@ -44,8 +45,13 @@ import { mockGetFeatureFlagClientstream, } from 'support/intercepts/feature-flags'; import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { + checkboxTestId, + headerTestId, +} from 'src/components/DiskEncryption/DiskEncryption'; import type { Config, VLAN, Disk, Region } from '@linode/api-v4'; +import { mockGetAccount } from 'support/intercepts/account'; const mockRegions: Region[] = [ regionFactory.build({ @@ -511,4 +517,74 @@ describe('create linode', () => { containsVisible(`eth2 – VPC: ${mockVPC.label}`); }); }); + + it('should not have a "Disk Encryption" section visible if the feature flag is off and user does not have capability', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(false), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Mock account response + const mockAccount = accountFactory.build({ + capabilities: ['Linodes'], + }); + + mockGetAccount(mockAccount).as('getAccount'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); + + // Check if section is visible + cy.get(`[data-testid=${headerTestId}]`).should('not.exist'); + }); + + it('should have a "Disk Encryption" section visible if feature flag is on and user has the capability', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Mock account response + const mockAccount = accountFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + const mockRegion = regionFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + const mockRegionWithoutDiskEncryption = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const mockRegions = [mockRegion, mockRegionWithoutDiskEncryption]; + + mockGetAccount(mockAccount).as('getAccount'); + mockGetRegions(mockRegions); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); + + // Check if section is visible + cy.get(`[data-testid="${headerTestId}"]`).should('exist'); + + // "Encrypt Disk" checkbox should be disabled if a region that does not support LDE is selected + ui.regionSelect.find().click(); + ui.select + .findItemByText( + `${mockRegionWithoutDiskEncryption.label} (${mockRegionWithoutDiskEncryption.id})` + ) + .click(); + + cy.get(`[data-testid="${checkboxTestId}"]`).should('be.disabled'); + + ui.regionSelect.find().click(); + ui.select.findItemByText(`${mockRegion.label} (${mockRegion.id})`).click(); + + cy.get(`[data-testid="${checkboxTestId}"]`).should('be.enabled'); + }); }); diff --git a/packages/manager/src/components/AccessPanel/AccessPanel.tsx b/packages/manager/src/components/AccessPanel/AccessPanel.tsx index 94f444657a0..3f68efe5304 100644 --- a/packages/manager/src/components/AccessPanel/AccessPanel.tsx +++ b/packages/manager/src/components/AccessPanel/AccessPanel.tsx @@ -2,8 +2,17 @@ import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; +import { + DISK_ENCRYPTION_GENERAL_DESCRIPTION, + DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY, +} from 'src/components/DiskEncryption/constants'; +import { DiskEncryption } from 'src/components/DiskEncryption/DiskEncryption'; +import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; import { Paper } from 'src/components/Paper'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { Typography } from 'src/components/Typography'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature'; import { Divider } from '../Divider'; import UserSSHKeyPanel from './UserSSHKeyPanel'; @@ -31,6 +40,8 @@ interface Props { className?: string; disabled?: boolean; disabledReason?: JSX.Element | string; + diskEncryptionEnabled?: boolean; + displayDiskEncryption?: boolean; error?: string; handleChange: (value: string) => void; heading?: string; @@ -41,8 +52,10 @@ interface Props { passwordHelperText?: string; placeholder?: string; required?: boolean; + selectedRegion?: string; setAuthorizedUsers?: (usernames: string[]) => void; small?: boolean; + toggleDiskEncryptionEnabled?: () => void; tooltipInteractive?: boolean; } @@ -52,6 +65,8 @@ export const AccessPanel = (props: Props) => { className, disabled, disabledReason, + diskEncryptionEnabled, + displayDiskEncryption, error, handleChange: _handleChange, hideStrengthLabel, @@ -61,15 +76,52 @@ export const AccessPanel = (props: Props) => { passwordHelperText, placeholder, required, + selectedRegion, setAuthorizedUsers, + toggleDiskEncryptionEnabled, tooltipInteractive, } = props; const { classes, cx } = useStyles(); + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); + + const regions = useRegionsQuery().data ?? []; + + const regionSupportsDiskEncryption = doesRegionSupportFeature( + selectedRegion ?? '', + regions, + 'Disk Encryption' + ); + const handleChange = (e: React.ChangeEvent) => _handleChange(e.target.value); + /** + * Display the "Disk Encryption" section if: + * 1) the feature is enabled + * 2) "displayDiskEncryption" is explicitly passed -- + * gets used in several places, but we don't want to display Disk Encryption in all + * 3) toggleDiskEncryptionEnabled is defined + */ + const diskEncryptionJSX = + isDiskEncryptionFeatureEnabled && + displayDiskEncryption && + toggleDiskEncryptionEnabled !== undefined ? ( + <> + + + + ) : null; + return ( { className )} > + {isDiskEncryptionFeatureEnabled && ( + ({ paddingBottom: theme.spacing(2) })} + variant="h2" + > + Security + + )} }> { /> ) : null} + {diskEncryptionJSX} ); }; diff --git a/packages/manager/src/components/DiskEncryption/DiskEncryption.test.tsx b/packages/manager/src/components/DiskEncryption/DiskEncryption.test.tsx index 7d99a8b9e46..46dbcb2fbb4 100644 --- a/packages/manager/src/components/DiskEncryption/DiskEncryption.test.tsx +++ b/packages/manager/src/components/DiskEncryption/DiskEncryption.test.tsx @@ -12,7 +12,11 @@ import { describe('DiskEncryption', () => { it('should render a header', () => { const { getByTestId } = renderWithTheme( - + ); const heading = getByTestId(headerTestId); @@ -23,7 +27,11 @@ describe('DiskEncryption', () => { it('should render a description', () => { const { getByTestId } = renderWithTheme( - + ); const description = getByTestId(descriptionTestId); @@ -33,7 +41,11 @@ describe('DiskEncryption', () => { it('should render a checkbox', () => { const { getByTestId } = renderWithTheme( - + ); const checkbox = getByTestId(checkboxTestId); diff --git a/packages/manager/src/components/DiskEncryption/DiskEncryption.tsx b/packages/manager/src/components/DiskEncryption/DiskEncryption.tsx index 3847cdd223d..e7cf9222262 100644 --- a/packages/manager/src/components/DiskEncryption/DiskEncryption.tsx +++ b/packages/manager/src/components/DiskEncryption/DiskEncryption.tsx @@ -8,8 +8,8 @@ export interface DiskEncryptionProps { descriptionCopy: JSX.Element | string; disabled?: boolean; disabledReason?: string; - // encryptionStatus - // toggleEncryption + isEncryptDiskChecked: boolean; + toggleDiskEncryptionEnabled: () => void; } export const headerTestId = 'disk-encryption-header'; @@ -17,9 +17,13 @@ export const descriptionTestId = 'disk-encryption-description'; export const checkboxTestId = 'encrypt-disk-checkbox'; export const DiskEncryption = (props: DiskEncryptionProps) => { - const { descriptionCopy, disabled, disabledReason } = props; - - const [checked, setChecked] = React.useState(false); // @TODO LDE: temporary placeholder until toggleEncryption logic is in place + const { + descriptionCopy, + disabled, + disabledReason, + isEncryptDiskChecked, + toggleDiskEncryptionEnabled, + } = props; return ( <> @@ -41,10 +45,10 @@ export const DiskEncryption = (props: DiskEncryptionProps) => { flexDirection="row" > setChecked(!checked)} // @TODO LDE: toggleEncryption will be used here + onChange={toggleDiskEncryptionEnabled} text="Encrypt Disk" toolTipText={disabled ? disabledReason : ''} /> diff --git a/packages/manager/src/components/DiskEncryption/constants.tsx b/packages/manager/src/components/DiskEncryption/constants.tsx index e38849fe918..dbdce42a9c7 100644 --- a/packages/manager/src/components/DiskEncryption/constants.tsx +++ b/packages/manager/src/components/DiskEncryption/constants.tsx @@ -16,3 +16,6 @@ export const DISK_ENCRYPTION_DESCRIPTION_NODE_POOL_REBUILD_CAVEAT = export const DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY = 'Disk encryption is not available in the selected region.'; + +export const DISK_ENCRYPTION_BACKUPS_CAVEAT_COPY = + 'Virtual Machine Backups are not encrypted.'; diff --git a/packages/manager/src/components/DiskEncryption/utils.ts b/packages/manager/src/components/DiskEncryption/utils.ts new file mode 100644 index 00000000000..8beaab70d68 --- /dev/null +++ b/packages/manager/src/components/DiskEncryption/utils.ts @@ -0,0 +1,31 @@ +import { useFlags } from 'src/hooks/useFlags'; +import { useAccount } from 'src/queries/account/account'; + +/** + * Hook to determine if the Disk Encryption feature should be visible to the user. + * Based on the user's account capability and the feature flag. + * + * @returns { boolean } - Whether the Disk Encryption feature is enabled for the current user. + */ +export const useIsDiskEncryptionFeatureEnabled = (): { + isDiskEncryptionFeatureEnabled: boolean; +} => { + const { data: account, error } = useAccount(); + const flags = useFlags(); + + if (error || !flags) { + return { isDiskEncryptionFeatureEnabled: false }; + } + + const hasAccountCapability = account?.capabilities?.includes( + 'Disk Encryption' + ); + + const isFeatureFlagEnabled = flags.linodeDiskEncryption; + + const isDiskEncryptionFeatureEnabled = Boolean( + hasAccountCapability && isFeatureFlagEnabled + ); + + return { isDiskEncryptionFeatureEnabled }; +}; diff --git a/packages/manager/src/factories/account.ts b/packages/manager/src/factories/account.ts index 79f8866b75d..770b4098e4d 100644 --- a/packages/manager/src/factories/account.ts +++ b/packages/manager/src/factories/account.ts @@ -36,17 +36,18 @@ export const accountFactory = Factory.Sync.makeFactory({ balance_uninvoiced: 0.0, billing_source: 'linode', capabilities: [ - 'Linodes', - 'NodeBalancers', 'Block Storage', - 'Object Storage', - 'Kubernetes', 'Cloud Firewall', - 'Vlans', + 'Disk Encryption', + 'Kubernetes', + 'Linodes', 'LKE HA Control Planes', 'Machine Images', 'Managed Databases', + 'NodeBalancers', + 'Object Storage', 'Placement Group', + 'Vlans', ], city: 'Colorado', company: Factory.each((i) => `company-${i}`), diff --git a/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.test.tsx index 1f2c13bc4e9..cf1e73cbeed 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.test.tsx @@ -31,6 +31,7 @@ const props: AddonsPanelProps = { changeBackups: vi.fn(), createType: 'fromLinode', disabled: false, + diskEncryptionEnabled: false, handleVLANChange: vi.fn(), ipamAddress: 'ipadAddress', ipamError: 'test ipad error', diff --git a/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.tsx index a90893d4389..d4811b676e8 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.tsx @@ -1,3 +1,4 @@ +import { useMediaQuery } from '@mui/material'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; import { Link } from 'react-router-dom'; @@ -5,6 +6,8 @@ import { Link } from 'react-router-dom'; import { Box } from 'src/components/Box'; import { Checkbox } from 'src/components/Checkbox'; import { Currency } from 'src/components/Currency'; +import { DISK_ENCRYPTION_BACKUPS_CAVEAT_COPY } from 'src/components/DiskEncryption/constants'; +import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; import { Divider } from 'src/components/Divider'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { Notice } from 'src/components/Notice/Notice'; @@ -32,6 +35,7 @@ export interface AddonsPanelProps { changeBackups: () => void; createType: CreateTypes; disabled?: boolean; + diskEncryptionEnabled: boolean; handleVLANChange: (updatedInterface: Interface) => void; ipamAddress: string; ipamError?: string; @@ -55,6 +59,7 @@ export const AddonsPanel = React.memo((props: AddonsPanelProps) => { changeBackups, createType, disabled, + diskEncryptionEnabled, handleVLANChange, ipamAddress, ipamError, @@ -73,6 +78,12 @@ export const AddonsPanel = React.memo((props: AddonsPanelProps) => { const theme = useTheme(); + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); + + const matchesMdUp = useMediaQuery(theme.breakpoints.up('md')); + const { data: image } = useImageQuery( selectedImageID ?? '', Boolean(selectedImageID) @@ -142,6 +153,9 @@ export const AddonsPanel = React.memo((props: AddonsPanelProps) => { } }, [selectedLinodeID]); + const isBackupsBoxChecked = + (accountBackups && !isEdgeRegionSelected) || props.backups; + return ( <> {showVlans && ( @@ -185,9 +199,6 @@ export const AddonsPanel = React.memo((props: AddonsPanelProps) => { { isBareMetal || isEdgeRegionSelected } + checked={isBackupsBoxChecked} data-testid="backups" onChange={changeBackups} /> @@ -208,6 +220,18 @@ export const AddonsPanel = React.memo((props: AddonsPanelProps) => { } /> + {isDiskEncryptionFeatureEnabled && + diskEncryptionEnabled && + isBackupsBoxChecked && ( + + )} {accountBackups && !isEdgeRegionSelected ? ( diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index 19084580f52..807f570a377 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -1,6 +1,7 @@ import { PlacementGroup } from '@linode/api-v4'; import { CreateLinodePlacementGroupPayload, + EncryptionStatus, InterfacePayload, PriceObject, restoreBackup, @@ -119,6 +120,7 @@ export interface LinodeCreateProps { autoassignIPv4WithinVPC: boolean; checkValidation: LinodeCreateValidation; createType: CreateTypes; + diskEncryptionEnabled: boolean; firewallId?: number; handleAgreementChange: () => void; handleFirewallChange: (firewallId: number) => void; @@ -148,6 +150,7 @@ export interface LinodeCreateProps { toggleAssignPublicIPv4Address: () => void; toggleAutoassignIPv4WithinVPCEnabled: () => void; toggleBackupsEnabled: () => void; + toggleDiskEncryptionEnabled: () => void; togglePrivateIPEnabled: () => void; typeDisplayInfo: TypeInfo; updateDiskSize: (size: number) => void; @@ -285,6 +288,7 @@ export class LinodeCreate extends React.PureComponent< const { selectedTab, stackScriptSelectedTab } = this.state; const { + account, accountBackupsEnabled, errors, flags, @@ -349,6 +353,13 @@ export class LinodeCreate extends React.PureComponent< this.props.backupsEnabled || accountBackupsEnabled ); + const hasDiskEncryptionAccountCapability = account.data?.capabilities?.includes( + 'Disk Encryption' + ); + + const isDiskEncryptionFeatureEnabled = + flags.linodeDiskEncryption && hasDiskEncryptionAccountCapability; + const displaySections = []; if (imageDisplayInfo) { displaySections.push(imageDisplayInfo); @@ -366,6 +377,12 @@ export class LinodeCreate extends React.PureComponent< selectedRegionID ?? '' ); + const regionSupportsDiskEncryption = doesRegionSupportFeature( + this.props.selectedRegionID ?? '', + this.props.regionsData, + 'Disk Encryption' + ); + if (typeDisplayInfo) { const typeDisplayInfoCopy = cloneDeep(typeDisplayInfo); @@ -423,6 +440,16 @@ export class LinodeCreate extends React.PureComponent< ); } + if ( + isDiskEncryptionFeatureEnabled && + regionSupportsDiskEncryption && + this.props.diskEncryptionEnabled + ) { + displaySections.push({ + title: 'Encrypted', + }); + } + if (this.props.vlanLabel) { displaySections.push({ title: 'VLAN Attached', @@ -698,12 +725,18 @@ export class LinodeCreate extends React.PureComponent< ? 'You must select an image to set a root password' : '' } + toggleDiskEncryptionEnabled={ + this.props.toggleDiskEncryptionEnabled + } authorizedUsers={this.props.authorized_users} data-qa-access-panel disabled={!this.props.selectedImageID || userCannotCreateLinode} + diskEncryptionEnabled={this.props.diskEncryptionEnabled} + displayDiskEncryption error={hasErrorFor.root_pass} handleChange={this.props.updatePassword} password={this.props.password} + selectedRegion={this.props.selectedRegionID} setAuthorizedUsers={this.props.setAuthorizedUsers} /> )} @@ -762,6 +795,9 @@ export class LinodeCreate extends React.PureComponent< /> )} { } autoassignIPv4WithinVPC={this.state.autoassignIPv4WithinVPCEnabled} checkValidation={this.checkValidation} + diskEncryptionEnabled={this.state.diskEncryptionEnabled ?? false} firewallId={this.state.selectedfirewallId} handleAgreementChange={this.handleAgreementChange} handleFirewallChange={this.handleFirewallChange} @@ -318,6 +321,7 @@ class LinodeCreateContainer extends React.PureComponent { setSelectedVPC={this.handleVPCChange} toggleAssignPublicIPv4Address={this.toggleAssignPublicIPv4Address} toggleBackupsEnabled={this.toggleBackupsEnabled} + toggleDiskEncryptionEnabled={this.toggleDiskEncryptionEnabled} togglePrivateIPEnabled={this.togglePrivateIPEnabled} typeDisplayInfo={this.getTypeInfo()} typesData={extendedTypeData} @@ -938,6 +942,10 @@ class LinodeCreateContainer extends React.PureComponent { toggleBackupsEnabled = () => this.setState({ backupsEnabled: !this.state.backupsEnabled }); + toggleDiskEncryptionEnabled = () => { + this.setState({ diskEncryptionEnabled: !this.state.diskEncryptionEnabled }); + }; + togglePrivateIPEnabled = () => this.setState({ privateIPEnabled: !this.state.privateIPEnabled }); diff --git a/packages/validation/.changeset/pr-10462-changed-1715896899402.md b/packages/validation/.changeset/pr-10462-changed-1715896899402.md new file mode 100644 index 00000000000..d925f5b751b --- /dev/null +++ b/packages/validation/.changeset/pr-10462-changed-1715896899402.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Changed +--- + +Adjust DiskEncryptionSchema so it is not an object ([#10462](https://github.com/linode/manager/pull/10462)) diff --git a/packages/validation/src/linodes.schema.ts b/packages/validation/src/linodes.schema.ts index f88834a0dd5..6553093ac9f 100644 --- a/packages/validation/src/linodes.schema.ts +++ b/packages/validation/src/linodes.schema.ts @@ -274,12 +274,10 @@ const PlacementGroupPayloadSchema = object({ id: number().notRequired().nullable(true), }); -const DiskEncryptionSchema = object({ - disk_encryption: string() - .oneOf(['enabled', 'disabled']) - .nullable() - .notRequired(), -}); +const DiskEncryptionSchema = string() + .oneOf(['enabled', 'disabled']) + .notRequired() + .nullable(true); export const CreateLinodeSchema = object({ type: string().ensure().required('Plan is required.'), From 8047ae7c469e57a9c537384097df8f270ee12e34 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 21 May 2024 14:18:46 -0400 Subject: [PATCH 006/163] fix: [M3-8112] - Reset errors in PlacementGroupDeleteModal (#10486) * reset error in PlacementGroupDeleteModal * Added changeset: Reset errors in PlacementGroupDeleteModal --- .../pr-10486-upcoming-features-1716217159383.md | 5 +++++ .../PlacementGroups/PlacementGroupsDeleteModal.tsx | 12 ++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-10486-upcoming-features-1716217159383.md diff --git a/packages/manager/.changeset/pr-10486-upcoming-features-1716217159383.md b/packages/manager/.changeset/pr-10486-upcoming-features-1716217159383.md new file mode 100644 index 00000000000..4f595dc7d54 --- /dev/null +++ b/packages/manager/.changeset/pr-10486-upcoming-features-1716217159383.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Reset errors in PlacementGroupDeleteModal ([#10486](https://github.com/linode/manager/pull/10486)) diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx index ac370b557d3..43b451c6cca 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx @@ -45,10 +45,12 @@ export const PlacementGroupsDeleteModal = (props: Props) => { error: deletePlacementError, isLoading: deletePlacementLoading, mutateAsync: deletePlacementGroup, + reset: resetDeletePlacementGroup, } = useDeletePlacementGroup(selectedPlacementGroup?.id ?? -1); const { error: unassignLinodeError, mutateAsync: unassignLinodes, + reset: resetUnassignLinodes, } = useUnassignLinodesFromPlacementGroup(selectedPlacementGroup?.id ?? -1); const [assignedLinodes, setAssignedLinodes] = React.useState< Linode[] | undefined @@ -85,6 +87,12 @@ export const PlacementGroupsDeleteModal = (props: Props) => { variant: 'success', } ); + handleClose(); + }; + + const handleClose = () => { + resetDeletePlacementGroup(); + resetUnassignLinodes(); onClose(); }; @@ -108,7 +116,7 @@ export const PlacementGroupsDeleteModal = (props: Props) => { width: 500, }, }} - onClose={onClose} + onClose={handleClose} open={open} title="Delete Placement Group" > @@ -130,7 +138,7 @@ export const PlacementGroupsDeleteModal = (props: Props) => { label="Placement Group" loading={deletePlacementLoading} onClick={onDelete} - onClose={onClose} + onClose={handleClose} open={open} title={`Delete Placement Group ${selectedPlacementGroup.label}`} > From c3320df8df87bb05a2a49e4538bb0cf46b498637 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 21 May 2024 15:12:42 -0400 Subject: [PATCH 007/163] upcoming: [M3-8092] - Linode Create Refactor - Scroll Errors Into View (#10454) * initial scrolling * improve stackscript error handling * update unit test * Added changeset: Linode Create Refactor - Scroll Errors Into View * improve ref doc * feedback @dwiley-akamai * use existing error scrolling utils * remove `tabIndex` * fixes and improvements * stable scrolling errors into view * make api/cli dialog scroll errors into view * cleaner way to handle scrollErrorIntoView CC @bnussman-akamai --------- Co-authored-by: Banks Nussman Co-authored-by: Alban Bailly --- ...r-10454-upcoming-features-1715611024690.md | 5 ++++ .../components/TabbedPanel/TabbedPanel.tsx | 2 +- .../Linodes/LinodeCreatev2/Access.tsx | 1 - .../Linodes/LinodeCreatev2/Actions.tsx | 6 ++++- .../LinodeCreatev2/Details/Details.tsx | 1 - .../Linodes/LinodeCreatev2/Region.tsx | 1 - .../LinodeCreatev2/Tabs/Clone/Clone.tsx | 10 -------- .../StackScripts/StackScriptSelection.tsx | 18 +++++++------- .../UserDefinedFields/utilities.test.ts | 1 + .../UserDefinedFields/utilities.ts | 8 ++++--- .../LinodeCreatev2/UserData/UserData.tsx | 1 - .../features/Linodes/LinodeCreatev2/VLAN.tsx | 1 - .../Linodes/LinodeCreatev2/VPC/VPC.tsx | 6 +---- .../Linodes/LinodeCreatev2/VPC/VPCRanges.tsx | 1 - .../features/Linodes/LinodeCreatev2/index.tsx | 24 ++++++++++++------- .../shared/LinodeSelectTable.tsx | 24 ++++++++++++------- .../Linodes/LinodeCreatev2/utilities.ts | 2 +- 17 files changed, 58 insertions(+), 54 deletions(-) create mode 100644 packages/manager/.changeset/pr-10454-upcoming-features-1715611024690.md diff --git a/packages/manager/.changeset/pr-10454-upcoming-features-1715611024690.md b/packages/manager/.changeset/pr-10454-upcoming-features-1715611024690.md new file mode 100644 index 00000000000..0e9637d3b7f --- /dev/null +++ b/packages/manager/.changeset/pr-10454-upcoming-features-1715611024690.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Linode Create Refactor - Scroll Errors Into View ([#10454](https://github.com/linode/manager/pull/10454)) diff --git a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx index 23bc8782e0a..5860f17cebc 100644 --- a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx +++ b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx @@ -22,8 +22,8 @@ export interface Tab { } interface TabbedPanelProps { - [index: string]: any; bodyClass?: string; + children?: React.ReactNode; copy?: string; docsLink?: JSX.Element; error?: JSX.Element | string; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Access.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Access.tsx index ce033562c43..32d69d7fb1d 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Access.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Access.tsx @@ -32,7 +32,6 @@ export const Access = () => { autoComplete="off" disabled={isLinodeCreateRestricted} errorText={fieldState.error?.message} - inputRef={field.ref} label="Root Password" name="password" noMarginTop diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx index 22c630358ba..557e9460766 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx @@ -5,6 +5,7 @@ import { useFormContext } from 'react-hook-form'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { ApiAwarenessModal } from '../LinodesCreate/ApiAwarenessModal/ApiAwarenessModal'; import { getLinodeCreatePayload } from './utilities'; @@ -23,8 +24,11 @@ export const Actions = () => { }); const onOpenAPIAwareness = async () => { - if (await trigger(undefined, { shouldFocus: true })) { + if (await trigger()) { + // If validation is successful, we open the dialog. setIsAPIAwarenessModalOpen(true); + } else { + scrollErrorIntoView(undefined, { behavior: 'smooth' }); } }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.tsx index 663c3462840..7d191db7f98 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.tsx @@ -30,7 +30,6 @@ export const Details = () => { { { - const { - formState: { errors }, - } = useFormContext(); - return ( Select Linode to Clone From - {errors.linode?.message && ( - - )} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelection.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelection.tsx index 63ea8bbd5d3..91bcffd5696 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelection.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/StackScriptSelection.tsx @@ -26,21 +26,19 @@ export const StackScriptSelection = () => { // Reset the selected image, the selected StackScript, and the StackScript data when changing tabs. reset((prev) => ({ ...prev, - image: null, - stackscript_data: null, - stackscript_id: null, + image: undefined, + stackscript_data: undefined, + stackscript_id: undefined, })); }; + + const error = formState.errors.stackscript_id?.message; + return ( Create From: - {formState.errors.stackscript_id && ( - + {error && ( + )} { ]; expect(getDefaultUDFData(udfs)).toStrictEqual({ + password: '', username: 'admin', }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.ts index c72c4f43fbc..869c91867dc 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/utilities.ts @@ -31,9 +31,11 @@ export const getIsUDFRequired = (udf: UserDefinedField) => export const getDefaultUDFData = ( userDefinedFields: UserDefinedField[] ): Record => - userDefinedFields.reduce((accum, eachField) => { - if (eachField.default) { - accum[eachField.name] = eachField.default; + userDefinedFields.reduce((accum, field) => { + if (field.default) { + accum[field.name] = field.default; + } else { + accum[field.name] = ''; } return accum; }, {}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx index e570a4f0013..53310b3c284 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserData.tsx @@ -97,7 +97,6 @@ export const UserData = () => { disabled={isLinodeCreateRestricted} errorText={fieldState.error?.message} expand - inputRef={field.ref} label="User Data" labelTooltipText="Compatible formats include cloud-config data and executable scripts." multiline diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/VLAN.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/VLAN.tsx index 1cae85da5ed..063caeebd3f 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/VLAN.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/VLAN.tsx @@ -104,7 +104,6 @@ export const VLAN = () => { containerProps={{ maxWidth: 335 }} disabled={disabled} errorText={fieldState.error?.message} - inputRef={field.ref} label="IPAM Address" onBlur={field.onBlur} onChange={field.onChange} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx index 6938cf80562..9da60ed6c84 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/VPC/VPC.tsx @@ -92,7 +92,6 @@ export const VPC = () => { : undefined } textFieldProps={{ - inputRef: field.ref, sx: (theme) => ({ [theme.breakpoints.up('sm')]: { minWidth: inputMaxWidth }, }), @@ -103,6 +102,7 @@ export const VPC = () => { filter={{ region: regionId }} label="Assign VPC" noMarginTop + onBlur={field.onBlur} onChange={(e, vpc) => field.onChange(vpc?.id ?? null)} placeholder="None" value={field.value ?? null} @@ -126,9 +126,6 @@ export const VPC = () => { getOptionLabel={(subnet) => `${subnet.label} (${subnet.ipv4})` } - textFieldProps={{ - inputRef: field.ref, - }} value={ selectedVPC?.subnets.find( (subnet) => subnet.id === field.value @@ -189,7 +186,6 @@ export const VPC = () => { { { const { params, setParams } = useLinodeCreateQueryParams(); + const formRef = useRef(null); - const methods = useForm({ + const form = useForm({ defaultValues, mode: 'onBlur', resolver: linodeCreateResolvers[params.type ?? 'Distributions'], + shouldFocusError: false, // We handle this ourselves with `scrollErrorIntoView` }); const history = useHistory(); @@ -67,12 +70,12 @@ export const LinodeCreatev2 = () => { // Update tab "type" query param. (This changes the selected tab) setParams({ type: newTab }); // Reset the form values - methods.reset(defaultValuesMap[newTab]); + form.reset(defaultValuesMap[newTab]); }; const onSubmit: SubmitHandler = async (values) => { const payload = getLinodeCreatePayload(values); - alert(JSON.stringify(payload, null, 2)); + try { const linode = params.type === 'Clone Linode' @@ -86,23 +89,28 @@ export const LinodeCreatev2 = () => { } catch (errors) { for (const error of errors) { if (error.field) { - methods.setError(error.field, { message: error.reason }); + form.setError(error.field, { message: error.reason }); } else { - methods.setError('root', { message: error.reason }); + form.setError('root', { message: error.reason }); } } } }; return ( - + - + + scrollErrorIntoViewV2(formRef) + )} + ref={formRef} + > diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx index 004fcab3f6b..41f10c8a424 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx @@ -1,10 +1,11 @@ import Grid from '@mui/material/Unstable_Grid2'; import useMediaQuery from '@mui/material/useMediaQuery'; import React, { useState } from 'react'; -import { useFormContext, useWatch } from 'react-hook-form'; +import { useController, useFormContext } from 'react-hook-form'; import { Box } from 'src/components/Box'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { Notice } from 'src/components/Notice/Notice'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { Stack } from 'src/components/Stack'; import { Table } from 'src/components/Table'; @@ -50,10 +51,12 @@ export const LinodeSelectTable = (props: Props) => { const { control, reset } = useFormContext(); - const selectedLinode = useWatch({ - control, - name: 'linode', - }); + const { field, fieldState } = useController( + { + control, + name: 'linode', + } + ); const { params } = useLinodeCreateQueryParams(); @@ -61,7 +64,7 @@ export const LinodeSelectTable = (props: Props) => { params.linodeID ); - const [query, setQuery] = useState(selectedLinode?.label ?? ''); + const [query, setQuery] = useState(field.value?.label ?? ''); const [linodeToPowerOff, setLinodeToPowerOff] = useState(); const pagination = usePagination(); @@ -103,6 +106,9 @@ export const LinodeSelectTable = (props: Props) => { return ( + {fieldState.error?.message && ( + + )} { @@ -111,7 +117,7 @@ export const LinodeSelectTable = (props: Props) => { } setQuery(value ?? ''); }, - value: preselectedLinodeId ? selectedLinode?.label ?? '' : query, + value: preselectedLinodeId ? field.value?.label ?? '' : query, }} clearable hideLabel @@ -162,7 +168,7 @@ export const LinodeSelectTable = (props: Props) => { key={linode.id} linode={linode} onSelect={() => handleSelect(linode)} - selected={linode.id === selectedLinode?.id} + selected={linode.id === field.value?.id} /> ))} @@ -174,7 +180,7 @@ export const LinodeSelectTable = (props: Props) => { handleSelection={() => handleSelect(linode)} key={linode.id} linode={linode} - selected={linode.id === selectedLinode?.id} + selected={linode.id === field.value?.id} /> ))} {data?.results === 0 && ( diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index 8824590eaf7..b1c76485e89 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -333,7 +333,7 @@ const defaultValuesForStackScripts = { defaultPublicInterface, ], region: '', - stackscript_id: null, + stackscript_id: undefined, type: '', }; From bb6c22c05bc4a54d16b72e172eb6d23566699f74 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Tue, 21 May 2024 15:23:05 -0400 Subject: [PATCH 008/163] test: [M3-7844] - Add script to generate JSON payload for TOD test results (#10422) * Added changeset: Add script to generate internal test results payload --- package.json | 1 + .../pr-10422-tech-stories-1714510098465.md | 5 ++ scripts/tod-payload/index.ts | 67 +++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 packages/manager/.changeset/pr-10422-tech-stories-1714510098465.md create mode 100644 scripts/tod-payload/index.ts diff --git a/package.json b/package.json index 4c7b5804b7d..4211ec10959 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "coverage": "yarn workspace linode-manager coverage", "coverage:summary": "yarn workspace linode-manager coverage:summary", "junit:summary": "ts-node scripts/junit-summary/index.ts", + "generate-tod": "ts-node scripts/tod-payload/index.ts", "docs": "bunx vitepress@1.0.0-rc.44 dev docs" }, "resolutions": { diff --git a/packages/manager/.changeset/pr-10422-tech-stories-1714510098465.md b/packages/manager/.changeset/pr-10422-tech-stories-1714510098465.md new file mode 100644 index 00000000000..dec693443fb --- /dev/null +++ b/packages/manager/.changeset/pr-10422-tech-stories-1714510098465.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Add script to generate internal test results payload ([#10422](https://github.com/linode/manager/pull/10422)) diff --git a/scripts/tod-payload/index.ts b/scripts/tod-payload/index.ts new file mode 100644 index 00000000000..7a337f4428e --- /dev/null +++ b/scripts/tod-payload/index.ts @@ -0,0 +1,67 @@ +/** + * @file Script to generate a TOD test results payload given a path containing JUnit XML files. + */ + +import { program } from 'commander'; +import * as fs from 'fs/promises'; +import { resolve } from 'path'; + +program + .name('tod-payload') + .description('Output TOD test result payload') + .version('0.1.0') + .arguments('') + .option('-n, --appName ', 'Application name') + .option('-b, --appBuild ', 'Application build identifier') + .option('-u, --appBuildUrl ', 'Application build URL') + .option('-v, --appVersion ', 'Application version') + .option('-t, --appTeam ', 'Application team name') + .option('-f, --fail', 'Treat payload as failure') + .option('-t, --tag ', 'Optional tag for run') + + .action((junitPath: string) => { + return main(junitPath); + }); + +const main = async (junitPath: string) => { + const resolvedJunitPath = resolve(junitPath); + + // Create an array of absolute file paths to JUnit XML report files. + // Account for cases where `resolvedJunitPath` is a path to a directory + // or a path to an individual JUnit file. + const junitFiles = await (async () => { + const stats = await fs.lstat(resolvedJunitPath); + if (stats.isDirectory()) { + return (await fs.readdir(resolvedJunitPath)) + .filter((dirItem: string) => { + return dirItem.endsWith('.xml') + }) + .map((dirItem: string) => { + return resolve(resolvedJunitPath, dirItem); + }); + } + return [resolvedJunitPath]; + })(); + + // Read all of the JUnit files. + const junitContents = await Promise.all(junitFiles.map((junitFile) => { + return fs.readFile(junitFile, 'utf8'); + })); + + const payload = JSON.stringify({ + team: program.opts()['appTeam'], + name: program.opts()['appName'], + buildName: program.opts()['appBuild'], + semanticVersion: program.opts()['appVersion'], + buildUrl: program.opts()['appBuildUrl'], + pass: !program.opts()['fail'], + tag: !!program.opts()['tag'] ? program.opts()['tag'] : undefined, + xunitResults: junitContents.map((junitContent) => { + return btoa(junitContent); + }), + }); + + console.log(payload); +}; + +program.parse(process.argv); From 82dc2419c5cfbdd677a93935c68df06c1854fd2d Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Tue, 21 May 2024 17:20:27 -0400 Subject: [PATCH 009/163] removed: [M3-7554, M3-7559] - Post Release Cleanup Parent/Child Feature Flags (#10497) Co-authored-by: Joe D'Amore Co-authored-by: Jaalah Ramos Co-authored-by: mjac0bs --- .../pr-10489-removed-1716225501082.md | 5 + .../core/account/account-cancellation.spec.ts | 11 - .../account/account-login-history.spec.ts | 29 +- .../e2e/core/account/display-settings.spec.ts | 11 - .../account/personal-access-tokens.spec.ts | 18 +- .../e2e/core/account/user-permissions.spec.ts | 20 -- .../account/user-verification-banner.spec.ts | 23 -- .../core/account/users-landing-page.spec.ts | 36 --- .../e2e/core/billing/billing-contact.spec.ts | 39 --- .../billing/restricted-user-billing.spec.ts | 259 +++++++----------- .../parentChild/account-switching.spec.ts | 20 -- .../core/parentChild/token-expiration.spec.ts | 13 - .../manager/src/dev-tools/FeatureFlagTool.tsx | 1 - packages/manager/src/featureFlags.ts | 1 - .../src/features/Account/AccountLanding.tsx | 5 +- .../src/features/Account/AccountLogins.tsx | 6 +- .../Account/CloseAccountSetting.test.tsx | 19 +- .../features/Account/CloseAccountSetting.tsx | 6 +- .../ContactInformation.test.tsx | 5 +- .../PaymentInformation.test.tsx | 5 +- .../GlobalNotifications.tsx | 6 +- .../Profile/APITokens/APITokenTable.tsx | 6 +- .../APITokens/CreateAPITokenDrawer.test.tsx | 16 +- .../APITokens/CreateAPITokenDrawer.tsx | 7 +- .../APITokens/ViewAPITokenDrawer.test.tsx | 30 +- .../Profile/APITokens/ViewAPITokenDrawer.tsx | 7 +- .../TopMenu/UserMenu/UserMenu.test.tsx | 32 +-- .../features/TopMenu/UserMenu/UserMenu.tsx | 22 +- .../features/TopMenu/UserMenu/utils.test.ts | 18 -- .../src/features/TopMenu/UserMenu/utils.ts | 9 +- .../src/features/Users/UserRow.test.tsx | 16 +- .../manager/src/features/Users/UserRow.tsx | 9 +- .../src/features/Users/UsersLanding.tsx | 14 +- 33 files changed, 166 insertions(+), 558 deletions(-) create mode 100644 packages/manager/.changeset/pr-10489-removed-1716225501082.md diff --git a/packages/manager/.changeset/pr-10489-removed-1716225501082.md b/packages/manager/.changeset/pr-10489-removed-1716225501082.md new file mode 100644 index 00000000000..1564d68e00b --- /dev/null +++ b/packages/manager/.changeset/pr-10489-removed-1716225501082.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Removed +--- + +`parentChildAccountAccess` feature flag ([#10489](https://github.com/linode/manager/pull/10489)) diff --git a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts index 3c114a06a7d..40a4b5761ef 100644 --- a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts @@ -27,11 +27,6 @@ import { } from 'support/util/random'; import type { CancelAccount } from '@linode/api-v4'; import { mockWebpageUrl } from 'support/intercepts/general'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; describe('Account cancellation', () => { /* @@ -325,12 +320,6 @@ describe('Parent/Child account cancellation', () => { const cancellationComments = randomPhrase(); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetAccount(mockAccount).as('getAccount'); mockGetProfile(mockProfile).as('getProfile'); mockCancelAccountError(cancellationPaymentErrorMessage, 409).as( diff --git a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts index 797e97edccf..bd33a939c29 100644 --- a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts @@ -6,12 +6,7 @@ import { profileFactory } from 'src/factories'; import { accountLoginFactory } from 'src/factories/accountLogin'; import { formatDate } from 'src/utilities/formatDate'; import { mockGetAccountLogins } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetProfile } from 'support/intercepts/profile'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { PARENT_USER } from 'src/features/Account/constants'; describe('Account login history', () => { @@ -42,15 +37,9 @@ describe('Account login history', () => { 'getAccountLogins' ); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Navigate to Account Login History page. cy.visitWithLogin('/account/login-history'); - cy.wait(['@getClientStream', '@getFeatureFlags', '@getProfile']); + cy.wait(['@getProfile']); // Confirm helper text above table is visible. cy.findByText( @@ -114,15 +103,9 @@ describe('Account login history', () => { mockGetProfile(mockProfile).as('getProfile'); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Navigate to Account Login History page. cy.visitWithLogin('/account/login-history'); - cy.wait(['@getClientStream', '@getFeatureFlags', '@getProfile']); + cy.wait(['@getProfile']); // Confirm helper text above table and table are not visible. cy.findByText( @@ -149,15 +132,9 @@ describe('Account login history', () => { mockGetProfile(mockProfile).as('getProfile'); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Navigate to Account Login History page. cy.visitWithLogin('/account/login-history'); - cy.wait(['@getClientStream', '@getFeatureFlags', '@getProfile']); + cy.wait(['@getProfile']); // Confirm helper text above table and table are not visible. cy.findByText( diff --git a/packages/manager/cypress/e2e/core/account/display-settings.spec.ts b/packages/manager/cypress/e2e/core/account/display-settings.spec.ts index 72f3a3bd68b..49eb9c30630 100644 --- a/packages/manager/cypress/e2e/core/account/display-settings.spec.ts +++ b/packages/manager/cypress/e2e/core/account/display-settings.spec.ts @@ -1,10 +1,5 @@ import { Profile } from '@linode/api-v4'; import { profileFactory } from '@src/factories'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { mockGetProfile } from 'support/intercepts/profile'; import { getProfile } from 'support/api/account'; import { interceptGetProfile } from 'support/intercepts/profile'; @@ -18,12 +13,6 @@ const verifyUsernameAndEmail = ( tooltip: string, checkEmail: boolean ) => { - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetProfile(mockRestrictedProxyProfile); // Navigate to User Profile page diff --git a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts index 5f616fe4de6..270f5568f23 100644 --- a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts +++ b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts @@ -15,11 +15,6 @@ import { } from 'support/intercepts/profile'; import { randomLabel, randomString } from 'support/util/random'; import { ui } from 'support/ui'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { PROXY_USER_RESTRICTED_TOOLTIP_TEXT } from 'src/features/Account/constants'; describe('Personal access tokens', () => { @@ -278,24 +273,13 @@ describe('Personal access tokens', () => { }); const proxyUserProfile = profileFactory.build({ user_type: 'proxy' }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetProfile(proxyUserProfile); mockGetPersonalAccessTokens([proxyToken]).as('getTokens'); mockGetAppTokens([]).as('getAppTokens'); mockRevokePersonalAccessToken(proxyToken.id).as('revokeToken'); cy.visitWithLogin('/profile/tokens'); - cy.wait([ - '@getClientStream', - '@getFeatureFlags', - '@getTokens', - '@getAppTokens', - ]); + cy.wait(['@getTokens', '@getAppTokens']); // Find token in list, confirm "Rename" is disabled and tooltip displays. cy.findByText(proxyToken.label) diff --git a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts index e67d5695332..d8f21fa1366 100644 --- a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts @@ -11,14 +11,9 @@ import { mockUpdateUser, mockUpdateUserGrants, } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetProfile } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { shuffleArray } from 'support/util/arrays'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; // Message shown when user has unrestricted account access. @@ -504,12 +499,6 @@ describe('User permission management', () => { global: { account_access: 'read_write' }, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockActiveUser, mockRestrictedUser]).as('getUsers'); mockGetUser(mockActiveUser); mockGetUserGrants(mockActiveUser.username, mockUserGrants); @@ -520,7 +509,6 @@ describe('User permission management', () => { ); mockGetUser(mockRestrictedUser); mockGetUserGrants(mockRestrictedUser.username, mockUserGrants); - cy.wait(['@getClientStream', '@getFeatureFlags']); cy.get('[data-qa-global-section]') .should('be.visible') @@ -573,12 +561,6 @@ describe('User permission management', () => { global: { account_access: 'read_write' }, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockRestrictedProxyUser]).as('getUsers'); mockGetUser(mockChildUser); mockGetUserGrants(mockChildUser.username, mockUserGrants); @@ -590,8 +572,6 @@ describe('User permission management', () => { `/account/users/${mockRestrictedProxyUser.username}/permissions` ); - cy.wait(['@getClientStream', '@getFeatureFlags']); - cy.findByText('Parent User Permissions', { exact: false }).should( 'be.visible' ); diff --git a/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts b/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts index 23279de91af..2b2fd767d4f 100644 --- a/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts @@ -7,13 +7,8 @@ import { mockGetUsers, } from 'support/intercepts/account'; import { mockGetSecurityQuestions } from 'support/intercepts/profile'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; import { mockGetProfile } from 'support/intercepts/profile'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { verificationBannerNotice } from 'support/constants/user'; describe('User verification banner', () => { @@ -46,12 +41,6 @@ describe('User verification banner', () => { global: { account_access: 'read_write' }, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream(); - mockGetUsers([mockRestrictedProxyUser]); mockGetUser(mockChildUser); mockGetUserGrants(mockChildUser.username, mockUserGrants); @@ -128,12 +117,6 @@ describe('User verification banner', () => { mockSecurityQuestions.security_questions[2].response = mockSecurityQuestionAnswers[2]; - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockRestrictedProxyUser]).as('getUsers'); mockGetUser(mockChildUser); mockGetUserGrants(mockChildUser.username, mockUserGrants); @@ -211,12 +194,6 @@ describe('User verification banner', () => { mockSecurityQuestions.security_questions[2].response = mockSecurityQuestionAnswers[2]; - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockRestrictedProxyUser]).as('getUsers'); mockGetUser(mockChildUser); mockGetUserGrants(mockChildUser.username, mockUserGrants); diff --git a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts index 26412953a56..4597d099fda 100644 --- a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts @@ -10,16 +10,11 @@ import { mockGetUsers, mockDeleteUser, } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetProfile, mockGetProfileGrants, } from 'support/intercepts/profile'; import { ui } from 'support/ui'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; import { PARENT_USER } from 'src/features/Account/constants'; @@ -65,12 +60,6 @@ const initTestUsers = (profile: Profile, enableChildAccountAccess: boolean) => { global: { child_account_access: enableChildAccountAccess }, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Initially mock user with unrestricted account access. mockGetUsers(mockUsers).as('getUsers'); mockGetUser(mockRestrictedParentWithoutChildAccountAccess); @@ -228,12 +217,6 @@ describe('Users landing page', () => { restricted: false, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - // Initially mock user with unrestricted account access. mockGetUsers([mockUser]).as('getUsers'); mockGetUser(mockUser); @@ -276,12 +259,6 @@ describe('Users landing page', () => { global: { account_access: 'read_write' }, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockRestrictedProxyUser]).as('getUsers'); mockGetUser(mockChildUser); mockGetUserGrants(mockChildUser.username, mockUserGrants); @@ -466,12 +443,6 @@ describe('Users landing page', () => { restricted: true, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockUser]).as('getUsers'); mockGetUser(mockUser); mockGetUserGrantsUnrestrictedAccess(mockUser.username); @@ -565,7 +536,6 @@ describe('Users landing page', () => { expect(intercept.request.body['restricted']).to.equal(newUser.restricted); }); cy.wait('@getUsers'); - cy.wait(['@getClientStream', '@getFeatureFlags']); // the new user is displayed in the user list cy.findByText(newUser.username).should('be.visible'); @@ -587,12 +557,6 @@ describe('Users landing page', () => { restricted: false, }); - // TODO: Parent/Child - M3-7559 clean up when feature is live in prod and feature flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetUsers([mockUser, additionalUser]).as('getUsers'); mockGetUser(mockUser); mockGetUserGrantsUnrestrictedAccess(mockUser.username); diff --git a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts index dbd4d28727f..0db765463af 100644 --- a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts @@ -2,16 +2,6 @@ import { mockGetAccount, mockUpdateAccount } from 'support/intercepts/account'; import { accountFactory } from 'src/factories/account'; import type { Account } from '@linode/api-v4'; import { ui } from 'support/ui'; -import { profileFactory } from '@src/factories'; - -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; - -import { mockGetProfile } from 'support/intercepts/profile'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { randomLabel } from 'support/util/random'; /* eslint-disable sonarjs/no-duplicate-string */ const accountData = accountFactory.build({ @@ -161,32 +151,3 @@ describe('Billing Contact', () => { }); }); }); - -describe('Parent/Child feature disabled', () => { - beforeEach(() => { - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(false), - }); - mockGetFeatureFlagClientstream(); - }); - - it('disables company name for Parent users', () => { - const mockProfile = profileFactory.build({ - username: randomLabel(), - restricted: false, - user_type: 'parent', - }); - - mockGetProfile(mockProfile); - cy.visitWithLogin('/account/billing/edit'); - - ui.drawer - .findByTitle('Edit Billing Contact Info') - .should('be.visible') - .within(() => { - cy.findByLabelText('Company Name') - .should('be.visible') - .should('be.disabled'); - }); - }); -}); diff --git a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts index 55566712282..6869a54a91b 100644 --- a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts @@ -7,16 +7,11 @@ import { accountUserFactory } from '@src/factories/accountUsers'; import { grantsFactory } from '@src/factories/grants'; import { ADMINISTRATOR, PARENT_USER } from 'src/features/Account/constants'; import { mockGetPaymentMethods, mockGetUser } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetProfile, mockGetProfileGrants, } from 'support/intercepts/profile'; import { ui } from 'support/ui'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; // Tooltip message that appears on disabled billing action buttons for restricted @@ -229,170 +224,118 @@ describe('restricted user billing flows', () => { mockGetPaymentMethods(mockPaymentMethods); }); - // TODO Delete all of these tests when Parent/Child launches and flag is removed. - describe('Parent/Child feature disabled', () => { - beforeEach(() => { - // Mock the Parent/Child feature flag to be enabled. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(false), - }); - mockGetFeatureFlagClientstream(); + /* + * - Confirms that users with read-only account access cannot edit billing information. + * - Confirms UX enhancements are applied when parent/child feature flag is enabled. + * - Confirms that "Edit" and "Add Payment Method" buttons are disabled and have informational tooltips. + * - Confirms that clicking "Edit" and "Add Payment Method" does not open their respective drawers when disabled. + * - Confirms that button tooltip text reflects read-only account access. + * - Confirms that payment method action menu items are disabled. + */ + it('cannot edit billing information with read-only account access', () => { + const mockProfile = profileFactory.build({ + username: randomLabel(), + restricted: true, + }); + + const mockUser = accountUserFactory.build({ + username: mockProfile.username, + restricted: true, + user_type: 'default', + }); + + const mockGrants = grantsFactory.build({ + global: { + account_access: 'read_only', + }, + }); + + mockGetProfile(mockProfile); + mockGetProfileGrants(mockGrants); + mockGetUser(mockUser); + cy.visitWithLogin('/account/billing'); + + assertEditBillingInfoDisabled(restrictedUserTooltip); + assertAddPaymentMethodDisabled(restrictedUserTooltip); + assertMakeAPaymentDisabled( + restrictedUserTooltip + + ` Please contact your ${ADMINISTRATOR} to request the necessary permissions.` + ); + }); + + /* + * - Confirms that child users cannot edit billing information. + * - Confirms that UX enhancements are applied when parent/child feature flag is enabled. + * - Confirms that "Edit" and "Add Payment Method" buttons are disabled and have informational tooltips. + * - Confirms that clicking "Edit" and "Add Payment Method" does not open their respective drawers when disabled. + * - Confirms that button tooltip text reflects child user access. + * - Confirms that payment method action menu items are disabled. + */ + it('cannot edit billing information as child account', () => { + const mockProfile = profileFactory.build({ + username: randomLabel(), + user_type: 'child', }); - /* - * - Smoke test to confirm that regular users can edit billing information. - * - Confirms that billing action buttons are enabled and open their respective drawers on click. - * - Confirms that payment method action menu items are enabled. - */ - it('can edit billing information', () => { - // The flow prior to Parent/Child does not account for user privileges, instead relying - // on the API to forbid actions when the user does not have the required privileges. - // Because the API is doing the heavy lifting, we only need to ensure that the billing action - // buttons behave as expected for this smoke test. - const mockProfile = profileFactory.build({ - username: randomLabel(), - restricted: false, - }); - - const mockUser = accountUserFactory.build({ - username: mockProfile.username, - user_type: 'default', - restricted: false, - }); - - // Confirm button behavior for regular users. - mockGetProfile(mockProfile); - mockGetUser(mockUser); - cy.visitWithLogin('/account/billing'); - assertEditBillingInfoEnabled(); - assertAddPaymentMethodEnabled(); - assertMakeAPaymentEnabled(); + const mockUser = accountUserFactory.build({ + username: mockProfile.username, }); + + mockGetProfile(mockProfile); + mockGetUser(mockUser); + cy.visitWithLogin('/account/billing'); + + assertEditBillingInfoDisabled(restrictedUserTooltip); + assertAddPaymentMethodDisabled(restrictedUserTooltip); + assertMakeAPaymentDisabled( + restrictedUserTooltip + + ` Please contact your ${PARENT_USER} to request the necessary permissions.` + ); }); - describe('Parent/Child feature enabled', () => { - beforeEach(() => { - // Mock the Parent/Child feature flag to be enabled. - // TODO Delete this `beforeEach()` block when Parent/Child launches and flag is removed. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); + /* + * - Smoke test to confirm that regular and parent users can edit billing information. + * - Confirms that billing action buttons are enabled and open their respective drawers on click. + */ + it('can edit billing information as a regular user and as a parent user', () => { + const mockProfileRegular = profileFactory.build({ + username: randomLabel(), + restricted: false, }); - /* - * - Confirms that users with read-only account access cannot edit billing information. - * - Confirms UX enhancements are applied when parent/child feature flag is enabled. - * - Confirms that "Edit" and "Add Payment Method" buttons are disabled and have informational tooltips. - * - Confirms that clicking "Edit" and "Add Payment Method" does not open their respective drawers when disabled. - * - Confirms that button tooltip text reflects read-only account access. - * - Confirms that payment method action menu items are disabled. - */ - it('cannot edit billing information with read-only account access', () => { - const mockProfile = profileFactory.build({ - username: randomLabel(), - restricted: true, - }); - - const mockUser = accountUserFactory.build({ - username: mockProfile.username, - restricted: true, - user_type: 'default', - }); - - const mockGrants = grantsFactory.build({ - global: { - account_access: 'read_only', - }, - }); - - mockGetProfile(mockProfile); - mockGetProfileGrants(mockGrants); - mockGetUser(mockUser); - cy.visitWithLogin('/account/billing'); - - assertEditBillingInfoDisabled(restrictedUserTooltip); - assertAddPaymentMethodDisabled(restrictedUserTooltip); - assertMakeAPaymentDisabled( - restrictedUserTooltip + - ` Please contact your ${ADMINISTRATOR} to request the necessary permissions.` - ); + const mockUserRegular = accountUserFactory.build({ + username: mockProfileRegular.username, + user_type: 'default', + restricted: false, }); - /* - * - Confirms that child users cannot edit billing information. - * - Confirms that UX enhancements are applied when parent/child feature flag is enabled. - * - Confirms that "Edit" and "Add Payment Method" buttons are disabled and have informational tooltips. - * - Confirms that clicking "Edit" and "Add Payment Method" does not open their respective drawers when disabled. - * - Confirms that button tooltip text reflects child user access. - * - Confirms that payment method action menu items are disabled. - */ - it('cannot edit billing information as child account', () => { - const mockProfile = profileFactory.build({ - username: randomLabel(), - user_type: 'child', - }); - - const mockUser = accountUserFactory.build({ - username: mockProfile.username, - }); - - mockGetProfile(mockProfile); - mockGetUser(mockUser); - cy.visitWithLogin('/account/billing'); - - assertEditBillingInfoDisabled(restrictedUserTooltip); - assertAddPaymentMethodDisabled(restrictedUserTooltip); - assertMakeAPaymentDisabled( - restrictedUserTooltip + - ` Please contact your ${PARENT_USER} to request the necessary permissions.` - ); + const mockProfileParent = profileFactory.build({ + username: randomLabel(), + restricted: false, }); - /* - * - Smoke test to confirm that regular and parent users can edit billing information. - * - Confirms that billing action buttons are enabled and open their respective drawers on click. - */ - it('can edit billing information as a regular user and as a parent user', () => { - const mockProfileRegular = profileFactory.build({ - username: randomLabel(), - restricted: false, - }); - - const mockUserRegular = accountUserFactory.build({ - username: mockProfileRegular.username, - user_type: 'default', - restricted: false, - }); - - const mockProfileParent = profileFactory.build({ - username: randomLabel(), - restricted: false, - }); - - const mockUserParent = accountUserFactory.build({ - username: mockProfileParent.username, - user_type: 'parent', - restricted: false, - }); - - // Confirm button behavior for regular users. - mockGetProfile(mockProfileRegular); - mockGetUser(mockUserRegular); - cy.visitWithLogin('/account/billing'); - cy.findByText(mockProfileRegular.username); - assertEditBillingInfoEnabled(); - assertAddPaymentMethodEnabled(); - assertMakeAPaymentEnabled(); - - // Confirm button behavior for parent users. - mockGetProfile(mockProfileParent); - mockGetUser(mockUserParent); - cy.visitWithLogin('/account/billing'); - cy.findByText(mockProfileParent.username); - assertEditBillingInfoEnabled(); - assertAddPaymentMethodEnabled(); - assertMakeAPaymentEnabled(); + const mockUserParent = accountUserFactory.build({ + username: mockProfileParent.username, + user_type: 'parent', + restricted: false, }); + + // Confirm button behavior for regular users. + mockGetProfile(mockProfileRegular); + mockGetUser(mockUserRegular); + cy.visitWithLogin('/account/billing'); + cy.findByText(mockProfileRegular.username); + assertEditBillingInfoEnabled(); + assertAddPaymentMethodEnabled(); + assertMakeAPaymentEnabled(); + + // Confirm button behavior for parent users. + mockGetProfile(mockProfileParent); + mockGetUser(mockUserParent); + cy.visitWithLogin('/account/billing'); + cy.findByText(mockProfileParent.username); + assertEditBillingInfoEnabled(); + assertAddPaymentMethodEnabled(); + assertMakeAPaymentEnabled(); }); }); diff --git a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts index cb1300418d2..c20ba294325 100644 --- a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts @@ -21,10 +21,6 @@ import { mockGetUser, } from 'support/intercepts/account'; import { mockGetEvents, mockGetNotifications } from 'support/intercepts/events'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockAllApiRequests } from 'support/intercepts/general'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { @@ -33,7 +29,6 @@ import { } from 'support/intercepts/profile'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { assertLocalStorageValue } from 'support/util/local-storage'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { grantsFactory } from '@src/factories/grants'; @@ -156,14 +151,6 @@ describe('Parent/Child account switching', () => { * Tests to confirm that Parent account users can switch to Child accounts as expected. */ describe('From Parent to Child', () => { - beforeEach(() => { - // @TODO M3-7554, M3-7559: Remove feature flag mocks after feature launch and clean-up. - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * - Confirms that Parent account user can switch to Child account from Account Billing page. * - Confirms that Child account information is displayed in user menu button after switch. @@ -326,13 +313,6 @@ describe('Parent/Child account switching', () => { * Tests to confirm that Parent account users can switch back from Child accounts as expected. */ describe('From Child to Parent', () => { - beforeEach(() => { - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * - Confirms that a Child account Proxy user can switch back to a Parent account from Billing page. * - Confirms that Parent account information is displayed in user menu button after switch. diff --git a/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts b/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts index bf91b178e3b..0c03b94d23e 100644 --- a/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts @@ -1,9 +1,4 @@ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetLinodes } from 'support/intercepts/linodes'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { accountFactory, accountUserFactory, @@ -33,14 +28,6 @@ const mockChildAccountProxyProfile = profileFactory.build({ }); describe('Parent/Child token expiration', () => { - // @TODO M3-7554, M3-7559: Remove feature flag mocks after launch and clean-up. - beforeEach(() => { - mockAppendFeatureFlags({ - parentChildAccountAccess: makeFeatureFlagData(true), - }); - mockGetFeatureFlagClientstream(); - }); - /* * - Confirms flow when a Proxy user attempts to switch back to a Parent account with expired auth token. * - Uses mock API and local storage data. diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 98daf91f982..1a96f9f0f08 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -25,7 +25,6 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'linodeCreateRefactor', label: 'Linode Create v2' }, { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, - { flag: 'parentChildAccountAccess', label: 'Parent/Child Account' }, { flag: 'placementGroups', label: 'Placement Groups' }, { flag: 'selfServeBetas', label: 'Self Serve Betas' }, { flag: 'supportTicketSeverity', label: 'Support Ticket Severity' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 9c0ecc17339..99353bc972b 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -74,7 +74,6 @@ export interface Flags { objMultiCluster: boolean; oneClickApps: OneClickApp; oneClickAppsDocsOverride: Record; - parentChildAccountAccess: boolean; placementGroups: PlacementGroupsFlag; productInformationBanners: ProductInformationBannerFlag[]; promos: boolean; diff --git a/packages/manager/src/features/Account/AccountLanding.tsx b/packages/manager/src/features/Account/AccountLanding.tsx index 44880338905..165b7665143 100644 --- a/packages/manager/src/features/Account/AccountLanding.tsx +++ b/packages/manager/src/features/Account/AccountLanding.tsx @@ -14,7 +14,6 @@ import { Tabs } from 'src/components/Tabs/Tabs'; import { switchAccountSessionContext } from 'src/context/switchAccountSessionContext'; import { useIsParentTokenExpired } from 'src/features/Account/SwitchAccounts/useIsParentTokenExpired'; import { getRestrictedResourceText } from 'src/features/Account/utils'; -import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useAccount } from 'src/queries/account/account'; import { useProfile } from 'src/queries/profile'; @@ -52,7 +51,6 @@ const AccountLanding = () => { const { data: account } = useAccount(); const { data: profile } = useProfile(); - const flags = useFlags(); const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); const sessionContext = React.useContext(switchAccountSessionContext); @@ -143,8 +141,7 @@ const AccountLanding = () => { const isBillingTabSelected = location.pathname.match(/billing/); const canSwitchBetweenParentOrProxyAccount = - flags.parentChildAccountAccess && - ((!isChildAccountAccessRestricted && isParentUser) || isProxyUser); + (!isChildAccountAccessRestricted && isParentUser) || isProxyUser; const landingHeaderProps: LandingHeaderProps = { breadcrumbProps: { diff --git a/packages/manager/src/features/Account/AccountLogins.tsx b/packages/manager/src/features/Account/AccountLogins.tsx index 2b07f1a1f8f..bbcf658fa40 100644 --- a/packages/manager/src/features/Account/AccountLogins.tsx +++ b/packages/manager/src/features/Account/AccountLogins.tsx @@ -16,7 +16,6 @@ import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; import { Typography } from 'src/components/Typography'; -import { useFlags } from 'src/hooks/useFlags'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useAccountLoginsQuery } from 'src/queries/account/logins'; @@ -44,7 +43,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ const AccountLogins = () => { const { classes } = useStyles(); const pagination = usePagination(1, preferenceKey); - const flags = useFlags(); const { handleOrderChange, order, orderBy } = useOrder( { @@ -69,9 +67,7 @@ const AccountLogins = () => { const { data: profile } = useProfile(); const isChildUser = profile?.user_type === 'child'; - const isRestrictedChildUser = Boolean( - flags.parentChildAccountAccess && isChildUser - ); + const isRestrictedChildUser = Boolean(isChildUser); const isAccountAccessRestricted = isRestrictedChildUser || profile?.restricted; diff --git a/packages/manager/src/features/Account/CloseAccountSetting.test.tsx b/packages/manager/src/features/Account/CloseAccountSetting.test.tsx index 8d6e1cb39d7..9117221c428 100644 --- a/packages/manager/src/features/Account/CloseAccountSetting.test.tsx +++ b/packages/manager/src/features/Account/CloseAccountSetting.test.tsx @@ -35,6 +35,10 @@ describe('Close Account Settings', () => { }); it('should render a Close Account Button', () => { + queryMocks.useProfile.mockReturnValue({ + data: profileFactory.build({ user_type: 'default' }), + }); + const { getByTestId } = renderWithTheme(); const button = getByTestId('close-account-button'); const span = button.querySelector('span'); @@ -49,10 +53,7 @@ describe('Close Account Settings', () => { }); const { getByRole, getByTestId, getByText } = renderWithTheme( - , - { - flags: { parentChildAccountAccess: true }, - } + ); const button = getByTestId('close-account-button'); fireEvent.mouseOver(button); @@ -73,10 +74,7 @@ describe('Close Account Settings', () => { }); const { getByRole, getByTestId, getByText } = renderWithTheme( - , - { - flags: { parentChildAccountAccess: true }, - } + ); const button = getByTestId('close-account-button'); fireEvent.mouseOver(button); @@ -97,10 +95,7 @@ describe('Close Account Settings', () => { }); const { getByRole, getByTestId, getByText } = renderWithTheme( - , - { - flags: { parentChildAccountAccess: true }, - } + ); const button = getByTestId('close-account-button'); fireEvent.mouseOver(button); diff --git a/packages/manager/src/features/Account/CloseAccountSetting.tsx b/packages/manager/src/features/Account/CloseAccountSetting.tsx index 440fe8b7993..23e260969bb 100644 --- a/packages/manager/src/features/Account/CloseAccountSetting.tsx +++ b/packages/manager/src/features/Account/CloseAccountSetting.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import { Accordion } from 'src/components/Accordion'; import { Button } from 'src/components/Button/Button'; -import { useFlags } from 'src/hooks/useFlags'; import { useProfile } from 'src/queries/profile'; import CloseAccountDialog from './CloseAccountDialog'; @@ -17,12 +16,9 @@ const CloseAccountSetting = () => { const [dialogOpen, setDialogOpen] = React.useState(false); const { data: profile } = useProfile(); - const flags = useFlags(); // Disable the Close Account button for users with a Parent/Proxy/Child user type. - const isCloseAccountDisabled = Boolean( - flags.parentChildAccountAccess && profile?.user_type !== 'default' - ); + const isCloseAccountDisabled = Boolean(profile?.user_type !== 'default'); let closeAccountButtonTooltipText; const userType = profile?.user_type; diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx index 7a0c9b1f7e3..59a2e8b65ec 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx @@ -47,10 +47,7 @@ describe('Edit Contact Information', () => { }); const { getByTestId } = renderWithTheme( - , - { - flags: { parentChildAccountAccess: true }, - } + ); expect(getByTestId(EDIT_BUTTON_ID)).toHaveAttribute( diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx index 6e66ab4a7d3..69ab5d5627d 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx @@ -145,10 +145,7 @@ describe('Payment Info Panel', () => { {...props} profile={queryMocks.useProfile().data} /> - , - { - flags: { parentChildAccountAccess: true }, - } + ); expect(getByTestId(ADD_PAYMENT_METHOD_BUTTON_ID)).toHaveAttribute( diff --git a/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx b/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx index 1f459809a46..69613c55293 100644 --- a/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx +++ b/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx @@ -23,10 +23,8 @@ export const GlobalNotifications = () => { const { data: profile } = useProfile(); const sessionContext = React.useContext(switchAccountSessionContext); const sessionExpirationContext = React.useContext(_sessionExpirationContext); - const isChildUser = - Boolean(flags.parentChildAccountAccess) && profile?.user_type === 'child'; - const isProxyUser = - Boolean(flags.parentChildAccountAccess) && profile?.user_type === 'proxy'; + const isChildUser = profile?.user_type === 'child'; + const isProxyUser = profile?.user_type === 'proxy'; const { data: securityQuestions } = useSecurityQuestions({ enabled: isChildUser, }); diff --git a/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx b/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx index 6eafb6c95f7..66cce009e4e 100644 --- a/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx +++ b/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx @@ -19,7 +19,6 @@ import { TableSortCell } from 'src/components/TableSortCell/TableSortCell'; import { Typography } from 'src/components/Typography'; import { PROXY_USER_RESTRICTED_TOOLTIP_TEXT } from 'src/features/Account/constants'; import { SecretTokenDialog } from 'src/features/Profile/SecretTokenDialog/SecretTokenDialog'; -import { useFlags } from 'src/hooks/useFlags'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useProfile } from 'src/queries/profile'; @@ -56,7 +55,6 @@ const PREFERENCE_KEY = 'api-tokens'; export const APITokenTable = (props: Props) => { const { title, type } = props; - const flags = useFlags(); const { data: profile } = useProfile(); const { handleOrderChange, order, orderBy } = useOrder( { @@ -83,9 +81,7 @@ export const APITokenTable = (props: Props) => { { '+order': order, '+order_by': orderBy } ); - const isProxyUser = Boolean( - flags.parentChildAccountAccess && profile?.user_type === 'proxy' - ); + const isProxyUser = Boolean(profile?.user_type === 'proxy'); const [isCreateOpen, setIsCreateOpen] = React.useState(false); const [isRevokeOpen, setIsRevokeOpen] = React.useState(false); diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx index 0d9ece4ab70..7b23c2f67f7 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { appTokenFactory } from 'src/factories'; import { grantsFactory } from 'src/factories/grants'; import { profileFactory } from 'src/factories/profile'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { CreateAPITokenDrawer } from './CreateAPITokenDrawer'; @@ -123,9 +123,7 @@ describe('Create API Token Drawer', () => { data: profileFactory.build({ user_type: 'parent' }), }); - const { getByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { getByText } = renderWithTheme(); const childScope = getByText('Child Account Access'); expect(childScope).toBeInTheDocument(); }); @@ -139,10 +137,7 @@ describe('Create API Token Drawer', () => { }); const { queryByText } = renderWithTheme( - , - { - flags: { parentChildAccountAccess: true }, - } + ); const childScope = queryByText('Child Account Access'); expect(childScope).not.toBeInTheDocument(); @@ -154,10 +149,7 @@ describe('Create API Token Drawer', () => { }); const { queryByText } = renderWithTheme( - , - { - flags: { parentChildAccountAccess: true }, - } + ); const childScope = queryByText('Child Account Access'); diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx index e2eacbe7679..71c93899c2c 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx @@ -17,7 +17,6 @@ import { TextField } from 'src/components/TextField'; import { ISO_DATETIME_NO_TZ_FORMAT } from 'src/constants'; import { AccessCell } from 'src/features/ObjectStorage/AccessKeyLanding/AccessCell'; import { VPC_READ_ONLY_TOOLTIP } from 'src/features/VPCs/constants'; -import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useProfile } from 'src/queries/profile'; import { useCreatePersonalAccessTokenMutation } from 'src/queries/tokens'; @@ -94,8 +93,6 @@ export const CreateAPITokenDrawer = (props: Props) => { const expiryTups = genExpiryTups(); const { onClose, open, showSecret } = props; - const flags = useFlags(); - const initialValues = { expiry: expiryTups[0][1], label: '', @@ -204,9 +201,7 @@ export const CreateAPITokenDrawer = (props: Props) => { // Visually hide the "Child Account Access" permission even though it's still part of the base perms. const hideChildAccountAccessScope = - profile?.user_type !== 'parent' || - isChildAccountAccessRestricted || - !flags.parentChildAccountAccess; + profile?.user_type !== 'parent' || isChildAccountAccessRestricted; return ( diff --git a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx index d526fa0fe7d..2a5a4e7f6c7 100644 --- a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx @@ -52,9 +52,7 @@ describe('View API Token Drawer', () => { data: profileFactory.build({ user_type: 'parent' }), }); - const { getByTestId } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { getByTestId } = renderWithTheme(); for (const permissionName of basePerms) { expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( ariaLabel, @@ -70,8 +68,7 @@ describe('View API Token Drawer', () => { }); const { getByTestId } = renderWithTheme( - , - { flags: { parentChildAccountAccess: true } } + ); for (const permissionName of basePerms) { expect(getByTestId(`perm-${permissionName}`)).toHaveAttribute( @@ -91,8 +88,7 @@ describe('View API Token Drawer', () => { , - { flags: { parentChildAccountAccess: true } } + /> ); for (const permissionName of basePerms) { // We only expect account to have read/write for this test @@ -117,8 +113,7 @@ describe('View API Token Drawer', () => { scopes: 'databases:read_only domains:read_write child_account:read_write events:read_write firewall:read_write images:read_write ips:read_write linodes:read_only lke:read_only longview:read_write nodebalancers:read_write object_storage:read_only stackscripts:read_write volumes:read_only vpc:read_write', })} - />, - { flags: { parentChildAccountAccess: true } } + /> ); const expectedScopeLevels = { @@ -150,29 +145,20 @@ describe('View API Token Drawer', () => { }); describe('Parent/Child: User Roles', () => { - const setupAndRender = (userType: UserType, enableFeatureFlag = true) => { + const setupAndRender = (userType: UserType) => { queryMocks.useProfile.mockReturnValue({ data: profileFactory.build({ user_type: userType }), }); - return renderWithTheme(, { - flags: { parentChildAccountAccess: enableFeatureFlag }, - }); + return renderWithTheme(); }; - const testChildScopeNotDisplayed = ( - userType: UserType, - enableFeatureFlag = true - ) => { - const { queryByText } = setupAndRender(userType, enableFeatureFlag); + const testChildScopeNotDisplayed = (userType: UserType) => { + const { queryByText } = setupAndRender(userType); const childScope = queryByText('Child Account Access'); expect(childScope).not.toBeInTheDocument(); }; - it('should not display the Child Account Access when feature flag is disabled', () => { - testChildScopeNotDisplayed('parent', false); - }); - it('should not display the Child Account Access scope for a user account without a parent user type', () => { testChildScopeNotDisplayed('default'); }); diff --git a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx index 87aa61b8be6..94339546e54 100644 --- a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx @@ -7,7 +7,6 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { AccessCell } from 'src/features/ObjectStorage/AccessKeyLanding/AccessCell'; -import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useProfile } from 'src/queries/profile'; @@ -27,8 +26,6 @@ interface Props { export const ViewAPITokenDrawer = (props: Props) => { const { onClose, open, token } = props; - const flags = useFlags(); - const { data: profile } = useProfile(); const isChildAccountAccessRestricted = useRestrictedGlobalGrantCheck({ @@ -39,9 +36,7 @@ export const ViewAPITokenDrawer = (props: Props) => { // Visually hide the "Child Account Access" permission even though it's still part of the base perms. const hideChildAccountAccessScope = - profile?.user_type !== 'parent' || - isChildAccountAccessRestricted || - !flags.parentChildAccountAccess; + profile?.user_type !== 'parent' || isChildAccountAccessRestricted; return ( diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx index f29c643feef..c8596ff8b42 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx @@ -31,9 +31,7 @@ describe('UserMenu', () => { }) ); - const { findByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByText } = renderWithTheme(); expect(await findByText('parent-user')).toBeInTheDocument(); expect(await findByText('Parent Company')).toBeInTheDocument(); @@ -56,9 +54,7 @@ describe('UserMenu', () => { }) ); - const { findByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByText } = renderWithTheme(); expect(await findByText('parent-user')).toBeInTheDocument(); expect(await findByText('Child Company')).toBeInTheDocument(); @@ -78,9 +74,7 @@ describe('UserMenu', () => { }) ); - const { findByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByText } = renderWithTheme(); expect(await findByText('child-user')).toBeInTheDocument(); expect(await findByText('Child Company')).toBeInTheDocument(); @@ -103,9 +97,7 @@ describe('UserMenu', () => { }) ); - const { findByText, queryByText } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByText, queryByText } = renderWithTheme(); expect(await findByText('regular-user')).toBeInTheDocument(); // Should not be displayed for regular users, only parent/child/proxy users. @@ -124,9 +116,7 @@ describe('UserMenu', () => { }) ); - const { findByLabelText, findByTestId } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByLabelText, findByTestId } = renderWithTheme(); const userMenuButton = await findByLabelText('Profile & Account'); fireEvent.click(userMenuButton); @@ -151,9 +141,7 @@ describe('UserMenu', () => { }) ); - const { findByLabelText, queryByTestId } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByLabelText, queryByTestId } = renderWithTheme(); const userMenuButton = await findByLabelText('Profile & Account'); fireEvent.click(userMenuButton); @@ -173,9 +161,7 @@ describe('UserMenu', () => { }) ); - const { findByLabelText, findByTestId } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByLabelText, findByTestId } = renderWithTheme(); const userMenuButton = await findByLabelText('Profile & Account'); fireEvent.click(userMenuButton); @@ -212,9 +198,7 @@ describe('UserMenu', () => { }) ); - const { findByLabelText, findByTestId } = renderWithTheme(, { - flags: { parentChildAccountAccess: true }, - }); + const { findByLabelText, findByTestId } = renderWithTheme(); const userMenuButton = await findByLabelText('Profile & Account'); fireEvent.click(userMenuButton); diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index 477e86e66af..2cfd5d58f69 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -21,7 +21,6 @@ import { switchAccountSessionContext } from 'src/context/switchAccountSessionCon import { SwitchAccountButton } from 'src/features/Account/SwitchAccountButton'; import { SwitchAccountDrawer } from 'src/features/Account/SwitchAccountDrawer'; import { useIsParentTokenExpired } from 'src/features/Account/SwitchAccounts/useIsParentTokenExpired'; -import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useAccount } from 'src/queries/account/account'; import { useGrants, useProfile } from 'src/queries/profile'; @@ -64,7 +63,6 @@ export const UserMenu = React.memo(() => { const { data: profile } = useProfile(); const { data: grants } = useGrants(); const { enqueueSnackbar } = useSnackbar(); - const flags = useFlags(); const sessionContext = React.useContext(switchAccountSessionContext); const hasGrant = (grant: GlobalGrantTypes) => @@ -72,39 +70,33 @@ export const UserMenu = React.memo(() => { const isRestrictedUser = profile?.restricted ?? false; const hasAccountAccess = !isRestrictedUser || hasGrant('account_access'); const hasReadWriteAccountAccess = hasGrant('account_access') === 'read_write'; - const hasParentChildAccountAccess = Boolean(flags.parentChildAccountAccess); const isParentUser = profile?.user_type === 'parent'; const isProxyUser = profile?.user_type === 'proxy'; const isChildAccountAccessRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'child_account_access', }); const canSwitchBetweenParentOrProxyAccount = - flags.parentChildAccountAccess && - ((!isChildAccountAccessRestricted && isParentUser) || isProxyUser); + (!isChildAccountAccessRestricted && isParentUser) || isProxyUser; const open = Boolean(anchorEl); const id = open ? 'user-menu-popover' : undefined; const companyNameOrEmail = getCompanyNameOrEmail({ company: account?.company, - isParentChildFeatureEnabled: hasParentChildAccountAccess, profile, }); const { isParentTokenExpired } = useIsParentTokenExpired({ isProxyUser }); // Used for fetching parent profile and account data by making a request with the parent's token. - const proxyHeaders = - hasParentChildAccountAccess && isProxyUser - ? { - Authorization: getStorage(`authentication/parent_token/token`), - } - : undefined; + const proxyHeaders = isProxyUser + ? { + Authorization: getStorage(`authentication/parent_token/token`), + } + : undefined; const { data: parentProfile } = useProfile({ headers: proxyHeaders }); - const userName = - (hasParentChildAccountAccess && isProxyUser ? parentProfile : profile) - ?.username ?? ''; + const userName = (isProxyUser ? parentProfile : profile)?.username ?? ''; const matchesSmDown = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm') diff --git a/packages/manager/src/features/TopMenu/UserMenu/utils.test.ts b/packages/manager/src/features/TopMenu/UserMenu/utils.test.ts index a1d8be27b1b..8a960cac142 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/utils.test.ts +++ b/packages/manager/src/features/TopMenu/UserMenu/utils.test.ts @@ -13,7 +13,6 @@ describe('getCompanyNameOrEmail', () => { newUserTypes.forEach((newUserType: UserType) => { const actual = getCompanyNameOrEmail({ company: MOCK_COMPANY_NAME, - isParentChildFeatureEnabled: true, profile: profileFactory.build({ user_type: newUserType }), }); const expected = MOCK_COMPANY_NAME; @@ -26,7 +25,6 @@ describe('getCompanyNameOrEmail', () => { const actual = getCompanyNameOrEmail({ company: undefined, - isParentChildFeatureEnabled: true, profile: profileFactory.build({ email: parentEmail, user_type: 'parent', @@ -41,7 +39,6 @@ describe('getCompanyNameOrEmail', () => { const actual = getCompanyNameOrEmail({ company: undefined, - isParentChildFeatureEnabled: true, profile: profileFactory.build({ email: childEmail, user_type: 'child', @@ -54,24 +51,9 @@ describe('getCompanyNameOrEmail', () => { it('returns undefined for the company/email of a regular (default) user', async () => { const actual = getCompanyNameOrEmail({ company: MOCK_COMPANY_NAME, - isParentChildFeatureEnabled: true, profile: profileFactory.build({ user_type: 'default' }), }); const expected = undefined; expect(actual).toEqual(expected); }); - - it('returns undefined for the company/email of all users when the parent/child feature is not enabled', async () => { - const allUserTypes = ['parent', 'child', 'proxy', 'default']; - - allUserTypes.forEach((userType: UserType) => { - const actual = getCompanyNameOrEmail({ - company: MOCK_COMPANY_NAME, - isParentChildFeatureEnabled: false, - profile: profileFactory.build({ user_type: userType }), - }); - const expected = undefined; - expect(actual).toEqual(expected); - }); - }); }); diff --git a/packages/manager/src/features/TopMenu/UserMenu/utils.ts b/packages/manager/src/features/TopMenu/UserMenu/utils.ts index 54b7ca28b75..d63d4b3307f 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/utils.ts +++ b/packages/manager/src/features/TopMenu/UserMenu/utils.ts @@ -2,7 +2,6 @@ import { Profile } from '@linode/api-v4'; export interface CompanyNameOrEmailOptions { company: string | undefined; - isParentChildFeatureEnabled: boolean; profile: Profile | undefined; } @@ -13,20 +12,16 @@ export interface CompanyNameOrEmailOptions { */ export const getCompanyNameOrEmail = ({ company, - isParentChildFeatureEnabled, profile, }: CompanyNameOrEmailOptions) => { - const isParentChildOrProxyUser = profile?.user_type !== 'default'; - const isParentUser = profile?.user_type === 'parent'; - // Return early if we do not need the company name or email. - if (!isParentChildFeatureEnabled || !profile || !isParentChildOrProxyUser) { + if (!profile || profile.user_type === 'default') { return undefined; } // For parent users lacking `account_access`: without a company name to identify an account, fall back on the email. // We do not need to do this for child users lacking `account_access` because we do not need to display the email. - if (isParentUser && !company) { + if (profile.user_type === 'parent' && !company) { return profile.email; } diff --git a/packages/manager/src/features/Users/UserRow.test.tsx b/packages/manager/src/features/Users/UserRow.test.tsx index eabb65e99d4..3841f20d1d3 100644 --- a/packages/manager/src/features/Users/UserRow.test.tsx +++ b/packages/manager/src/features/Users/UserRow.test.tsx @@ -66,9 +66,7 @@ describe('UserRow', () => { ); const { findByText } = renderWithTheme( - wrapWithTableBody(, { - flags: { parentChildAccountAccess: true }, - }) + wrapWithTableBody() ); expect(await findByText('Enabled')).toBeVisible(); }); @@ -91,9 +89,7 @@ describe('UserRow', () => { ); const { findByText } = renderWithTheme( - wrapWithTableBody(, { - flags: { parentChildAccountAccess: true }, - }) + wrapWithTableBody() ); expect(await findByText('Disabled')).toBeVisible(); }); @@ -118,9 +114,7 @@ describe('UserRow', () => { ); const { queryByText } = renderWithTheme( - wrapWithTableBody(, { - flags: { parentChildAccountAccess: true }, - }) + wrapWithTableBody() ); expect(queryByText('Enabled')).not.toBeInTheDocument(); }); @@ -145,9 +139,7 @@ describe('UserRow', () => { ); const { findByText, queryByText } = renderWithTheme( - wrapWithTableBody(, { - flags: { parentChildAccountAccess: true }, - }) + wrapWithTableBody() ); // Renders Username, Email, and Account Access fields for a proxy user. diff --git a/packages/manager/src/features/Users/UserRow.tsx b/packages/manager/src/features/Users/UserRow.tsx index 9d90bd63c44..2f959791f2c 100644 --- a/packages/manager/src/features/Users/UserRow.tsx +++ b/packages/manager/src/features/Users/UserRow.tsx @@ -10,7 +10,6 @@ import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; -import { useFlags } from 'src/hooks/useFlags'; import { useAccountUserGrants } from 'src/queries/account/users'; import { useProfile } from 'src/queries/profile'; import { capitalize } from 'src/utilities/capitalize'; @@ -25,15 +24,11 @@ interface Props { } export const UserRow = ({ onDelete, user }: Props) => { - const flags = useFlags(); const { data: grants } = useAccountUserGrants(user.username); const { data: profile } = useProfile(); - const isProxyUser = Boolean( - flags.parentChildAccountAccess && user.user_type === 'proxy' - ); - const showChildAccountAccessCol = - flags.parentChildAccountAccess && profile?.user_type === 'parent'; + const isProxyUser = Boolean(user.user_type === 'proxy'); + const showChildAccountAccessCol = profile?.user_type === 'parent'; return ( diff --git a/packages/manager/src/features/Users/UsersLanding.tsx b/packages/manager/src/features/Users/UsersLanding.tsx index 2997ec92bb1..f927a5dfe8c 100644 --- a/packages/manager/src/features/Users/UsersLanding.tsx +++ b/packages/manager/src/features/Users/UsersLanding.tsx @@ -10,9 +10,9 @@ import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { Typography } from 'src/components/Typography'; import { PARENT_USER } from 'src/features/Account/constants'; -import { useFlags } from 'src/hooks/useFlags'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useAccountUsers } from 'src/queries/account/users'; import { useProfile } from 'src/queries/profile'; @@ -23,7 +23,6 @@ import { UsersLandingTableBody } from './UsersLandingTableBody'; import { UsersLandingTableHead } from './UsersLandingTableHead'; import type { Filter } from '@linode/api-v4'; -import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; export const UsersLanding = () => { const theme = useTheme(); @@ -32,7 +31,6 @@ export const UsersLanding = () => { ); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); const [selectedUsername, setSelectedUsername] = React.useState(''); - const flags = useFlags(); const { data: profile } = useProfile(); const matchesSmDown = useMediaQuery(theme.breakpoints.down('sm')); const matchesLgUp = useMediaQuery(theme.breakpoints.up('lg')); @@ -41,8 +39,7 @@ export const UsersLanding = () => { const order = useOrder(); const showProxyUserTable = - flags.parentChildAccountAccess && - (profile?.user_type === 'child' || profile?.user_type === 'proxy'); + profile?.user_type === 'child' || profile?.user_type === 'proxy'; const usersFilter: Filter = { ['+order']: order.order, @@ -67,8 +64,7 @@ export const UsersLanding = () => { error: proxyUserError, isInitialLoading: isLoadingProxyUser, } = useAccountUsers({ - enabled: - flags.parentChildAccountAccess && showProxyUserTable && !isRestrictedUser, + enabled: showProxyUserTable && !isRestrictedUser, filters: { user_type: 'proxy' }, }); @@ -77,9 +73,7 @@ export const UsersLanding = () => { }); const showChildAccountAccessCol = Boolean( - flags.parentChildAccountAccess && - profile?.user_type === 'parent' && - !isChildAccountAccessRestricted + profile?.user_type === 'parent' && !isChildAccountAccessRestricted ); // Parent/Child accounts include additional "child account access" column. From abb762ab2fd02db0a765108f1a91279cf9659838 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 22 May 2024 09:16:53 -0400 Subject: [PATCH 010/163] fix: [M3-8124] - `RegionSelect` unexpected keyboard behavior (#10495) * remove one-off `onKeyDown` logic * Added changeset: `RegionSelect` unexpected keyboard behavior --------- Co-authored-by: Banks Nussman --- packages/manager/.changeset/pr-10495-fixed-1716307990793.md | 5 +++++ .../manager/src/components/RegionSelect/RegionSelect.tsx | 6 ------ 2 files changed, 5 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-10495-fixed-1716307990793.md diff --git a/packages/manager/.changeset/pr-10495-fixed-1716307990793.md b/packages/manager/.changeset/pr-10495-fixed-1716307990793.md new file mode 100644 index 00000000000..6c15b21bcfa --- /dev/null +++ b/packages/manager/.changeset/pr-10495-fixed-1716307990793.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +`RegionSelect` unexpected keyboard behavior ([#10495](https://github.com/linode/manager/pull/10495)) diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 71a8451ab38..d1603412ef5 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -112,12 +112,6 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { onChange={(_, selectedOption: RegionSelectOption) => { handleRegionChange(selectedOption); }} - onKeyDown={(e) => { - if (e.key !== 'Tab') { - setSelectedRegion(null); - handleRegionChange(null); - } - }} renderOption={(props, option) => { return ( Date: Wed, 22 May 2024 10:17:05 -0400 Subject: [PATCH 011/163] upcoming: [M3-7874] - Linode Create Refactor - More Marketplace Progress (#10488) * add new details map and details drawer * small clean up * add unit test * clean up * fix icon url --------- Co-authored-by: Banks Nussman --- .../assets/white/{lamp_flame.svg => lamp.svg} | 0 .../Tabs/Marketplace/AppDetailDrawer.test.tsx | 56 + .../Tabs/Marketplace/AppDetailDrawer.tsx | 213 ++ .../Tabs/Marketplace/AppSelect.test.tsx | 8 +- .../Tabs/Marketplace/AppSelect.tsx | 52 +- .../Tabs/Marketplace/AppSelectionCard.tsx | 2 +- .../Tabs/Marketplace/Marketplace.tsx | 16 +- .../src/features/OneClickApps/oneClickApps.ts | 3 + .../features/OneClickApps/oneClickAppsv2.ts | 2459 +++++++++++++++++ 9 files changed, 2784 insertions(+), 25 deletions(-) rename packages/manager/public/assets/white/{lamp_flame.svg => lamp.svg} (100%) create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.test.tsx create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.tsx create mode 100644 packages/manager/src/features/OneClickApps/oneClickAppsv2.ts diff --git a/packages/manager/public/assets/white/lamp_flame.svg b/packages/manager/public/assets/white/lamp.svg similarity index 100% rename from packages/manager/public/assets/white/lamp_flame.svg rename to packages/manager/public/assets/white/lamp.svg diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.test.tsx new file mode 100644 index 00000000000..da8b1684d81 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.test.tsx @@ -0,0 +1,56 @@ +import { userEvent } from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AppDetailDrawerv2 } from './AppDetailDrawer'; + +describe('AppDetailDrawer', () => { + it('should render an app', () => { + const { getByText } = renderWithTheme( + + ); + + // Verify title renders + expect(getByText('WordPress')).toBeVisible(); + + // Verify description renders + expect( + getByText( + 'Flexible, open source content management system (CMS) for content-focused websites of any kind.' + ) + ).toBeVisible(); + + // Verify website renders with link + const website = getByText('https://wordpress.org/'); + expect(website).toBeVisible(); + expect(website).toHaveAttribute('href', 'https://wordpress.org/'); + + // Verify guide renders with link + const guide = getByText('Deploy WordPress through the Linode Marketplace'); + expect(guide).toBeVisible(); + expect(guide).toHaveAttribute( + 'href', + 'https://www.linode.com/docs/products/tools/marketplace/guides/wordpress/' + ); + }); + + it('should call onClose if the close button is clicked', async () => { + const onClose = vi.fn(); + const { getByLabelText } = renderWithTheme( + + ); + + await userEvent.click(getByLabelText('Close drawer')); + + expect(onClose).toHaveBeenCalled(); + }); + + it('should not render if open is false', async () => { + const { container } = renderWithTheme( + + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.tsx new file mode 100644 index 00000000000..6d6118c2d66 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppDetailDrawer.tsx @@ -0,0 +1,213 @@ +import Close from '@mui/icons-material/Close'; +import Drawer from '@mui/material/Drawer'; +import IconButton from '@mui/material/IconButton'; +import { Theme } from '@mui/material/styles'; +import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; + +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; +import { Link } from 'src/components/Link'; +import { Typography } from 'src/components/Typography'; +import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; +import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; + +const useStyles = makeStyles()((theme: Theme) => ({ + appName: { + color: '#fff !important', + fontFamily: theme.font.bold, + fontSize: '2.2rem', + lineHeight: '2.5rem', + textAlign: 'center', + }, + button: { + color: 'white !important', + margin: theme.spacing(2), + position: 'absolute', + }, + container: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + padding: theme.spacing(4), + }, + description: { + lineHeight: 1.5, + marginBottom: theme.spacing(2), + marginTop: theme.spacing(2), + }, + image: { + width: 50, + }, + link: { + fontSize: '0.875rem', + lineHeight: '24px', + wordBreak: 'break-word', + }, + logoContainer: { + gap: theme.spacing(), + height: 225, + padding: theme.spacing(2), + }, + paper: { + [theme.breakpoints.up('sm')]: { + width: 480, + }, + }, +})); + +interface Props { + onClose: () => void; + open: boolean; + stackScriptId: number | undefined; +} + +export const AppDetailDrawerv2 = (props: Props) => { + const { onClose, open, stackScriptId } = props; + const { classes } = useStyles(); + + const selectedApp = stackScriptId ? oneClickApps[stackScriptId] : null; + + const gradient = { + backgroundImage: `url(/assets/marketplace-background.png),linear-gradient(to right, #${selectedApp?.colors.start}, #${selectedApp?.colors.end})`, + }; + + return ( + + + + + + + {selectedApp ? ( + <> + + {`${selectedApp.name} + + + + + {selectedApp.summary} + + + {selectedApp.website && ( + + Website + + {selectedApp.website} + + + )} + {selectedApp.related_guides && ( + + Guides + + {selectedApp.related_guides.map((link, idx) => ( + + {sanitizeHTML({ + sanitizingTier: 'flexible', + text: link.title, + })} + + ))} + + + )} + {selectedApp.tips && ( + + Tips + + {selectedApp.tips.map((tip, idx) => ( + + {tip} + + ))} + + + )} + + + ) : ( + + App Details Not Found + + We were unable to load the details of this app. + + + + )} + + ); +}; + +// remove this when we make the svgs white via css +const REUSE_WHITE_ICONS = { + 'mongodbmarketplaceocc.svg': 'mongodb.svg', + 'postgresqlmarketplaceocc.svg': 'postgresql.svg', + 'redissentinelmarketplaceocc.svg': 'redis.svg', +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.test.tsx index c47d0a85166..182f7ec02fe 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.test.tsx @@ -12,7 +12,7 @@ import { uniqueCategories } from './utilities'; describe('Marketplace', () => { it('should render a header', () => { const { getByText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const heading = getByText('Select an App'); @@ -23,7 +23,7 @@ describe('Marketplace', () => { it('should render a search field', () => { const { getByPlaceholderText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const input = getByPlaceholderText('Search for app name'); @@ -34,7 +34,7 @@ describe('Marketplace', () => { it('should render a category select', () => { const { getByPlaceholderText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const input = getByPlaceholderText('Select category'); @@ -55,7 +55,7 @@ describe('Marketplace', () => { getByPlaceholderText, getByText, } = renderWithThemeAndHookFormContext({ - component: , + component: , }); await waitFor(() => { diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx index 294f4cb653b..bb62cd608e3 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx @@ -10,6 +10,7 @@ import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Paper } from 'src/components/Paper'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; +import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; import { getDefaultUDFData } from '../StackScripts/UserDefinedFields/utilities'; @@ -18,13 +19,23 @@ import { categoryOptions } from './utilities'; import type { LinodeCreateFormValues } from '../../utilities'; -export const AppSelect = () => { +interface Props { + /** + * Opens the Marketplace App details drawer for the given app + */ + onOpenDetailsDrawer: (stackscriptId: number) => void; +} + +export const AppSelect = (props: Props) => { + const { onOpenDetailsDrawer } = props; const { setValue } = useFormContext(); const { field } = useController({ name: 'stackscript_id', }); - const { data: apps, error, isLoading } = useMarketplaceAppsQuery(true); + const { data: stackscripts, error, isLoading } = useMarketplaceAppsQuery( + true + ); const renderContent = () => { if (isLoading) { @@ -47,22 +58,27 @@ export const AppSelect = () => { return ( - {apps?.map((app) => ( - { - setValue( - 'stackscript_data', - getDefaultUDFData(app.user_defined_fields) - ); - field.onChange(app.id); - }} - checked={field.value === app.id} - iconUrl={app.logo_url} - key={app.label} - label={app.label} - onOpenDetailsDrawer={() => alert('details')} - /> - ))} + {stackscripts?.map((stackscript) => { + if (!oneClickApps[stackscript.id]) { + return null; + } + return ( + { + setValue( + 'stackscript_data', + getDefaultUDFData(stackscript.user_defined_fields) + ); + field.onChange(stackscript.id); + }} + checked={field.value === stackscript.id} + iconUrl={`/assets/${oneClickApps[stackscript.id].logo_url}`} + key={stackscript.id} + label={stackscript.label} + onOpenDetailsDrawer={() => onOpenDetailsDrawer(stackscript.id)} + /> + ); + })} ); }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelectionCard.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelectionCard.tsx index 67261c65a98..44bc65f9d06 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelectionCard.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelectionCard.tsx @@ -51,7 +51,7 @@ export const AppSelectionCard = (props: Props) => { const renderIcon = iconUrl === '' ? () => - : () => {`${label}; + : () => {`${label}; const renderVariant = () => ( { + const [drawerStackScriptId, setDrawerStackScriptId] = useState(); + + const onOpenDetailsDrawer = (stackscriptId: number) => { + setDrawerStackScriptId(stackscriptId); + }; + return ( - + + setDrawerStackScriptId(undefined)} + open={drawerStackScriptId !== undefined} + stackScriptId={drawerStackScriptId} + /> ); }; diff --git a/packages/manager/src/features/OneClickApps/oneClickApps.ts b/packages/manager/src/features/OneClickApps/oneClickApps.ts index f5cb1bea442..64f5906ca52 100644 --- a/packages/manager/src/features/OneClickApps/oneClickApps.ts +++ b/packages/manager/src/features/OneClickApps/oneClickApps.ts @@ -2,6 +2,9 @@ import { oneClickAppFactory } from 'src/factories/stackscripts'; import type { OCA } from './types'; +/** + * @deprecated See oneClickAppsv2.ts + */ export const oneClickApps: OCA[] = [ { alt_description: 'Free open source control panel with a mobile app.', diff --git a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts new file mode 100644 index 00000000000..f8106bcd63e --- /dev/null +++ b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts @@ -0,0 +1,2459 @@ +import { oneClickAppFactory } from 'src/factories/stackscripts'; + +import type { OCA } from './types'; + +/** + * This object maps a StackScript ID to additional information. + * + * A marketplace app must be listed here with the correct ID + * for it to be visible to users. + */ +export const oneClickApps: Record = { + 0: { + ...oneClickAppFactory.build({ + name: 'E2E Test App', + }), + }, + 401697: { + alt_description: 'Popular website content management system.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: '135478', + start: '176086', + }, + description: `With 60 million users around the globe, WordPress is the industry standard for custom websites such as blogs, news sites, personal websites, and anything in-between. With a focus on best in class usability and flexibility, you can have a customized website up and running in minutes.`, + logo_url: 'wordpress.svg', + name: 'WordPress', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/wordpress/', + title: 'Deploy WordPress through the Linode Marketplace', + }, + ], + summary: + 'Flexible, open source content management system (CMS) for content-focused websites of any kind.', + website: 'https://wordpress.org/', + }, + 401698: { + alt_description: 'Secure website CMS.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: '1b64a5', + start: '0678be', + }, + description: `Drupal is a content management system (CMS) designed for building custom websites for personal and business use. Built for high performance and scalability, Drupal provides the necessary tools to create rich, interactive community websites with forums, user blogs, and private messaging. Drupal also has support for personal publishing projects and can power podcasts, blogs, and knowledge-based systems, all within a single, unified platform.`, + logo_url: 'drupal.svg', + name: 'Drupal', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/drupal/', + title: 'Deploy Drupal through the Linode Marketplace', + }, + ], + summary: `Powerful content management system built on PHP and supported by a database engine.`, + website: 'https://www.drupal.org/', + }, + 401701: { + alt_description: 'Essential software stack for Linux applications.', + alt_name: 'Web stack', + categories: ['Stacks'], + colors: { + end: 'bfa477', + start: '3c4043', + }, + description: `The LAMP stack consists of the Linux operating system, the Apache HTTP Server, the MySQL relational database management system, and the PHP programming language. This software environment is a foundation for popular PHP application + frameworks like WordPress, Drupal, and Laravel. Upload your existing PHP application code to your new app or use a PHP framework to write a new application on the Linode.`, + logo_url: 'lamp.svg', + name: 'LAMP', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/lamp-stack/', + title: 'Deploy a LAMP Stack through the Linode Marketplace', + }, + ], + summary: `Build PHP-based applications with the LAMP software stack: Linux, Apache, MySQL, and PHP.`, + }, + 401702: { + alt_description: 'React and Node.js stack.', + alt_name: 'Web stack', + categories: [], + colors: { + end: '256291', + start: '30383a', + }, + description: `MERN is a full stack platform that contains everything you need to build a web application: MongoDB, a document database used to persist your application's data; Express, which serves as the web application framework; React, used to build your application's user interfaces; + and Node.js, which serves as the run-time environment for your application. All of these technologies are well-established, offer robust feature sets, and are well-supported by their maintaining organizations. These characteristics make them a great choice for your applications. Upload your + existing MERN website code to your new Linode, or use MERN's scaffolding tool to start writing new web applications on the Linode.`, + logo_url: 'mern.svg', + name: 'MERN', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mern-stack/', + title: 'Deploy a MERN Stack through the Linode Marketplace', + }, + ], + summary: `Build production-ready apps with the MERN stack: MongoDB, Express, React, and Node.js.`, + }, + 401706: { + alt_description: 'Virtual private network.', + alt_name: 'Free VPN', + categories: ['Security'], + colors: { + end: '51171a', + start: '88171a', + }, + description: `Configuring WireGuard® is as simple as configuring SSH. A connection is established by an exchange of public keys between server and client, and only a client whose public key is present in the server's configuration file is considered authorized. WireGuard sets up + standard network interfaces which behave similarly to other common network interfaces, like eth0. This makes it possible to configure and manage WireGuard interfaces using standard networking tools such as ifconfig and ip. "WireGuard" is a registered trademark of Jason A. Donenfeld.`, + logo_url: 'wireguard.svg', + name: 'WireGuard®', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/wireguard/', + title: 'Deploy WireGuard through the Linode Marketplace', + }, + ], + summary: `Modern VPN which utilizes state-of-the-art cryptography. It aims to be faster and leaner than other VPN protocols and has a smaller source code footprint.`, + website: 'https://www.wireguard.com/', + }, + 401707: { + alt_description: 'Popular Git management tool.', + alt_name: 'Git repository hosting', + categories: ['Development'], + colors: { + end: '21153e', + start: '48357d', + }, + description: `GitLab is a complete solution for all aspects of your software development. At its core, GitLab serves as your centralized Git repository. GitLab also features built-in tools that represent every task in your development workflow, from planning to testing to releasing. + Self-hosting your software development with GitLab offers total control of your codebase. At the same time, its familiar interface will ease collaboration for you and your team. GitLab is the most popular self-hosted Git repository, so you'll benefit from a robust set of integrated tools and an active community.`, + logo_url: 'gitlab.svg', + name: 'GitLab', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/gitlab/', + title: 'Deploy GitLab through the Linode Marketplace', + }, + ], + summary: + 'More than a self-hosted Git repository: use GitLab to manage all the stages of your DevOps life cycle.', + website: 'https://about.gitlab.com/', + }, + 401708: { + alt_description: 'Popular secure WordPress ecommerce online store plugin.', + alt_name: 'Ecommerce site', + categories: ['Website'], + colors: { + end: '743b8a', + start: '96588a', + }, + description: `With WooCommerce, you can securely sell both digital and physical goods, and take payments via major credit cards, bank transfers, PayPal, and other providers like Stripe. With more than 300 extensions to choose from, WooCommerce is extremely flexible.`, + logo_url: 'woocommerce.svg', + name: 'WooCommerce', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/woocommerce/', + title: 'Deploy WooCommerce through the Linode Marketplace', + }, + ], + summary: `Highly customizable, secure, open source eCommerce platform built to integrate with Wordpress.`, + website: 'https://woocommerce.com/features/', + }, + 401709: { + alt_description: 'Classic open world survival crafting game.', + alt_name: 'World building game', + categories: ['Games'], + colors: { + end: 'd0c8c4', + start: '97948f', + }, + description: `With over 100 million users around the world, Minecraft is the most popular online game of all time. Less of a game and more of a lifestyle choice, you and other players are free to build and explore in a 3D generated world made up of millions of mineable blocks. Collect resources by leveling mountains, + taming forests, and venturing out to sea. Choose a home from the varied list of biomes like ice worlds, flower plains, and jungles. Build ancient castles or modern mega cities, and fill them with redstone circuit contraptions and villagers. Fight off nightly invasions of Skeletons, Zombies, and explosive + Creepers, or adventure to the End and the Nether to summon the fabled End Dragon and the chaotic Wither. If that is not enough, Minecraft is also highly moddable and customizable. You decide the rules when hosting your own Minecraft server for you and your friends to play together in this highly addictive game.`, + logo_url: 'minecraft.svg', + name: 'Minecraft: Java Edition', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/minecraft/', + title: 'Deploy a Minecraft Server through the Linode Marketplace', + }, + ], + summary: `Build, explore, and adventure in your own 3D generated world.`, + website: 'https://www.minecraft.net/', + }, + 401719: { + alt_description: 'Popular virtual private network.', + alt_name: 'Free VPN', + categories: ['Security'], + colors: { + end: '193766', + start: 'ea7e20', + }, + description: `OpenVPN is a widely trusted, free, and open-source virtual private network application. OpenVPN creates network tunnels between groups of computers that are not on the same local network, and it uses OpenSSL to encrypt your traffic.`, + logo_url: 'openvpn.svg', + name: 'OpenVPN', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/openvpn/', + title: 'Deploy OpenVPN through the Linode Marketplace', + }, + ], + summary: `Open-source virtual private network (VPN) application. OpenVPN securely connects your computer to your servers, or to the public Internet.`, + website: 'https://openvpn.net/', + }, + 593835: { + alt_description: 'Popular WordPress server management.', + alt_name: 'WordPress control panel', + categories: ['Control Panels'], + colors: { + end: '4b5868', + start: '53bce6', + }, + description: `Plesk is a leading WordPress and website management platform and control panel. Plesk lets you build and manage multiple websites from a single dashboard to configure web services, email, and other applications. Plesk features hundreds of extensions, plus a complete WordPress toolkit. Use the Plesk One-Click App to manage websites hosted on your Linode.`, + logo_url: 'plesk.svg', + name: 'Plesk', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/plesk/', + title: 'Deploy Plesk through the Linode Marketplace', + }, + ], + summary: + 'A secure, scalable, and versatile website and WordPress management platform.', + website: 'https://www.plesk.com/', + }, + 595742: { + alt_description: + 'Linux-based web hosting control panel for managing websites, servers, databases, and more.', + alt_name: 'Web server automation and control panel', + categories: ['Control Panels'], + colors: { + end: '141d25', + start: 'ff6c2c', + }, + description: `The cPanel & WHM® Marketplace App streamlines publishing and managing a website on your Linode. cPanel & WHM is a Linux® based web hosting control panel and platform that helps you create and manage websites, servers, databases and more with a suite of hosting automation and optimization tools.`, + logo_url: 'cpanel.svg', + name: 'cPanel', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/cpanel/', + title: 'Deploy cPanel through the Linode Marketplace', + }, + ], + summary: + 'The leading hosting automation platform that has simplified site and server management for 20 years.', + website: 'https://www.cpanel.net/', + }, + 604068: { + alt_description: 'Secure SOCKS5 web proxy with data encryption.', + alt_name: 'VPN proxy', + categories: ['Security'], + colors: { + end: '8d8d8d', + start: '227dc0', + }, + description: + 'Shadowsocks is a lightweight SOCKS5 web proxy tool. A full setup requires a Linode server to host the Shadowsocks daemon, and a client installed on PC, Mac, Linux, or a mobile device. Unlike other proxy software, Shadowsocks traffic is designed to be both indiscernible from other traffic to third-party monitoring tools, and also able to disguise itself as a normal direct connection. Data passing through Shadowsocks is encrypted for additional security and privacy.', + logo_url: 'shadowsocks.svg', + name: 'Shadowsocks', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/shadowsocks/', + title: 'Deploy Shadowsocks through the Linode Marketplace', + }, + ], + summary: + 'A secure socks5 proxy, designed to protect your Internet traffic.', + website: 'https://shadowsocks.org/', + }, + 606691: { + alt_description: 'Essential software stack for Linux applications.', + alt_name: 'Web stack', + categories: ['Stacks'], + colors: { + end: '005138', + start: '2e7d32', + }, + description: `LEMP provides a platform for applications that is compatible with the LAMP stack for nearly all applications; however, because NGINX is able to serve more pages at once with a more predictable memory usage profile, it may be more suited to high demand situations.`, + logo_url: 'lemp.svg', + name: 'LEMP', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/lemp-stack/', + title: 'Deploy a LEMP Stack through the Linode Marketplace', + }, + ], + summary: `The LEMP stack replaces the Apache web server component with NGINX (“Engine-X”), providing the E in the acronym: Linux, NGINX, MySQL/MariaDB, PHP.`, + }, + 607026: { + alt_description: 'SQL database.', + alt_name: 'SQL database', + categories: ['Databases'], + colors: { + end: '8a9177', + start: '1d758f', + }, + description: `MySQL, or MariaDB for Linux distributions, is primarily used for web and server applications, including as a component of the industry-standard LAMP and LEMP stacks.`, + logo_url: 'mysql.svg', + name: 'MySQL/MariaDB', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mysql/', + title: 'Deploy MySQL/MariaDB through the Linode Marketplace', + }, + ], + summary: `World's most popular open source database.`, + website: 'https://www.mysql.com/', + }, + 607401: { + alt_description: 'CI/CD tool to delegate automation tasks and jobs.', + alt_name: 'Free automation tool', + categories: ['Development'], + colors: { + end: 'd24939', + start: 'd33833', + }, + description: `Jenkins is an open source automation tool which can build, test, and deploy your infrastructure.`, + logo_url: 'jenkins.svg', + name: 'Jenkins', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/jenkins/', + title: 'Deploy Jenkins through the Linode Marketplace', + }, + ], + summary: `A tool that gives you access to a massive library of plugins to support automation in your project's lifecycle.`, + website: 'https://jenkins.io/', + }, + 607433: { + alt_description: + 'Popular container tool to build cloud-native applications.', + alt_name: 'Container builder', + categories: ['Development'], + colors: { + end: '1e65c9', + start: '2496ed', + }, + description: `Docker is a tool that enables you to create, deploy, and manage lightweight, stand-alone packages that contain everything needed to run an application (code, libraries, runtime, system settings, and dependencies).`, + logo_url: 'docker.svg', + name: 'Docker', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/docker/', + title: 'Deploy Docker through the Linode Marketplace', + }, + ], + summary: `Securely build, share and run modern applications anywhere.`, + website: 'https://www.docker.com/', + }, + 607488: { + alt_description: 'In-memory caching database.', + alt_name: 'High performance database', + categories: ['Databases'], + colors: { + end: '722b20', + start: '222222', + }, + description: `Redis® is an open-source, in-memory, data-structure store, with the optional ability to write and persist data to a disk, which can be used as a key-value database, cache, and message broker. Redis® features built-in transactions, replication, and support for a variety of data structures such as strings, hashes, lists, sets, and others.

*Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. Any use by Akamai Technologies is for referential purposes only and does not indicate any sponsorship, endorsement or affiliation between Redis and Akamai Technologies.`, + logo_url: 'redis.svg', + name: 'Marketplace App for Redis®', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/redis/', + title: 'Deploy Redis® through the Linode Marketplace', + }, + ], + summary: + 'Flexible, in-memory, NoSQL database service supported in many different coding languages.', + website: 'https://redis.io/', + }, + 609018: { + alt_description: + 'Web interface for MySQL/MariaDB operations and server administration.', + alt_name: 'SQL database GUI', + categories: ['Databases'], + colors: { + end: '6c78af', + start: 'f89d10', + }, + description: `Intuitive web interface for MySQL and MariaDB operations, including importing/exporting data, administering multiple servers, and global database search.`, + logo_url: 'phpmyadmin.svg', + name: 'phpMyAdmin', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/phpmyadmin/', + title: 'Deploy phpMyAdmin through the Linode Marketplace', + }, + ], + summary: 'Popular free administration tool for MySQL and MariaDB.', + website: 'https://www.phpmyadmin.net/', + }, + 609048: { + alt_description: 'Ruby web application framework with development tools.', + alt_name: 'Web application framework', + categories: ['Development'], + colors: { + end: 'fa9999', + start: '722b20', + }, + description: `Rails is a web application development framework written in the Ruby programming language. It is designed to make programming web applications easier by giving every developer a number of common tools they need to get started. Ruby on Rails empowers you to accomplish more with less code.`, + logo_url: 'rubyonrails.svg', + name: 'Ruby on Rails', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/ruby-on-rails/', + title: 'Deploy Ruby on Rails through the Linode Marketplace', + }, + ], + summary: `Ruby on Rails is a web framework that allows web designers and developers to implement dynamic, fully featured web applications.`, + website: 'https://rubyonrails.org/', + }, + 609175: { + alt_description: 'Fast Python development with best practices.', + alt_name: 'Python framework', + categories: ['Development'], + colors: { + end: '136149', + start: '0a2e1f', + }, + description: `Django is a web development framework for the Python programming language. It enables rapid development, while favoring pragmatic and clean design.`, + logo_url: 'django.svg', + name: 'Django', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/django/', + title: 'Deploy Django through the Linode Marketplace', + }, + ], + summary: `A framework for simplifying the process of building your web applications more quickly and with less code.`, + website: 'https://www.djangoproject.com/', + }, + 609392: { + alt_description: 'Fast Python development with best practices.', + alt_name: 'Python framework', + categories: ['Development'], + colors: { + end: '1e2122', + start: '363b3d', + }, + description: `Flask is a lightweight WSGI web application framework written in Python. It is designed to make getting started quick and easy, with the ability to scale up to complex applications.`, + logo_url: 'flask.svg', + name: 'Flask', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/flask/', + title: 'Deploy Flask through the Linode Marketplace', + }, + ], + summary: `A quick light-weight web framework for Python that includes several utilities and libraries you can use to create a web application.`, + website: 'https://www.palletsprojects.com/p/flask/', + }, + 611376: { + alt_description: 'MySQL alternative for SQL database.', + alt_name: 'SQL database', + categories: ['Databases'], + colors: { + end: '254078', + start: '326690', + }, + description: `PostgreSQL is a popular open source relational database system that provides many advanced configuration options that can help optimize your database’s performance in a production environment.`, + logo_url: 'postgresql.svg', + name: 'PostgreSQL', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/postgresql/', + title: 'Deploy PostgreSQL through the Linode Marketplace', + }, + ], + summary: `The PostgreSQL relational database system is a powerful, scalable, and standards-compliant open-source database platform.`, + website: 'https://www.postgresql.org/', + }, + 611895: { + alt_description: 'Angular and Node.js stack.', + alt_name: 'Web framework', + categories: ['Development'], + colors: { + end: '686868', + start: '323232', + }, + description: `MEAN is a full-stack JavaScript-based framework which accelerates web application development much faster than other frameworks. All involved technologies are well-established, offer robust feature sets, and are well-supported by their maintaining organizations. These characteristics make them a great choice for your applications.`, + logo_url: 'mean.svg', + name: 'MEAN', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mean-stack/', + title: 'Deploy a MEAN Stack through the Linode Marketplace', + }, + ], + summary: `A MEAN (MongoDB, Express, Angular, Node.js) stack is a free and open-source web software bundle used to build modern web applications.`, + website: 'http://meanjs.org/', + }, + 632758: { + alt_description: + 'File storage alternative to Dropbox and office suite alternative to Microsoft Office.', + alt_name: 'File storage management & business tool suite', + categories: ['Productivity'], + colors: { + end: '2a2a36', + start: '16a5f3', + }, + description: `Nextcloud AIO stands for Nextcloud All In One, and provides easy deployment and maintenance for popular Nextcloud tools. AIO includes Nextcloud, Nextcloud Office, OnlyOffice, and high-performance backend features.`, + logo_url: 'nextcloud.svg', + name: 'Nextcloud', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/nextcloud/', + title: 'Deploy Nextcloud through the Linode Marketplace', + }, + ], + summary: `A safe home for all your data.`, + }, + 662118: { + alt_description: 'Free internet radio station management and hosting.', + alt_name: 'Online radio station builder', + categories: ['Media and Entertainment'], + colors: { + end: '0b1b64', + start: '1f8df5', + }, + description: `All aspects of running a radio station in one web interface so you can start your own station. Manage media, create playlists, and interact with listeners on one free platform.`, + logo_url: 'azuracast.svg', + name: 'Azuracast', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/azuracast/', + title: 'Deploy AzuraCast through the Linode Marketplace', + }, + ], + summary: 'Open source, self-hosted web radio tool.', + website: 'https://www.azuracast.com/', + }, + 662119: { + alt_description: + 'Video / media library storage and sharing across TVs, phones, computers, and more.', + alt_name: 'Media server', + categories: [], + colors: { + end: '332c37', + start: 'e5a00d', + }, + description: `Organize, stream, and share your media library with friends, in addition to free live TV in 220+ countries.`, + logo_url: 'plex.svg', + name: 'Plex', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/plex/', + title: 'Deploy Plex Media Server through the Linode Marketplace', + }, + ], + summary: + 'Media server and streaming service to stay entertained across devices.', + website: 'https://www.plex.tv/', + }, + 662121: { + alt_description: 'Open source video conferencing alternative to Zoom.', + alt_name: 'Video chat and video conferencing', + categories: ['Media and Entertainment'], + colors: { + end: '949699', + start: '1d76ba', + }, + description: `Secure, stable, and free alternative to popular video conferencing services. Use built-in features to limit meeting access with passwords or stream on YouTube so anyone can attend.`, + logo_url: 'jitsi.svg', + name: 'Jitsi', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/jitsi/', + title: 'Deploy Jitsi through the Linode Marketplace', + }, + ], + summary: 'Free, open source video conferencing and communication platform.', + website: 'https://jitsi.org/', + }, + 688890: { + alt_description: 'Server work queue management.', + alt_name: 'Message broker', + categories: ['Development'], + colors: { + end: 'ff6600', + start: 'a9b5af', + }, + description: `Connect and scale applications with asynchronous messaging and highly available work queues, all controlled through an intuitive management UI.`, + logo_url: 'rabbitmq.svg', + name: 'RabbitMQ', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/rabbitmq/', + title: 'Deploy RabbitMQ through the Linode Marketplace', + }, + ], + summary: 'Most popular open source message broker.', + website: 'https://www.rabbitmq.com/', + }, + 688891: { + alt_description: 'Open source community forum alternative to Reddit.', + alt_name: 'Chat forum', + categories: ['Media and Entertainment'], + colors: { + end: 'eae692', + start: '13b3ed', + }, + description: `Launch a sleek forum with robust integrations to popular tools like Slack and WordPress to start more conversations.`, + logo_url: 'discourse.svg', + name: 'Discourse', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/discourse/', + title: 'Deploy Discourse through the Linode Marketplace', + }, + ], + summary: + 'Open source community and discussion forum for customers, teams, fans, and more.', + website: 'https://www.discourse.org/', + }, + 688902: { + alt_description: + 'Control panel to deploy and manage LAMP stack applications.', + alt_name: 'Single user control panel', + categories: ['Control Panels'], + colors: { + end: '445289', + start: 'f1b55d', + }, + description: `Lightweight control panel with a suite of features to streamline app management.`, + logo_url: 'webuzo.svg', + name: 'Webuzo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/webuzo/', + title: 'Deploy Webuzo through the Linode Marketplace', + }, + ], + summary: + 'LAMP stack and single user control panel to simplify app deployment in the cloud.', + website: 'http://www.webuzo.com/', + }, + 688903: { + alt_description: 'Fancy development text editor.', + alt_name: 'Text editor', + categories: ['Development'], + colors: { + end: '0066b8', + start: '23a9f2', + }, + description: `Launch a portable development environment to speed up tests, downloads, and more.`, + logo_url: 'vscodeserver.svg', + name: 'VS Code Server', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/vscode/', + title: 'Deploy VS Code through the Linode Marketplace', + }, + ], + summary: 'Run VS code in the cloud, right from your browser.', + website: 'https://github.com/cdr/code-server', + }, + 688911: { + alt_description: 'Open source, self-hosted Git management tool.', + alt_name: 'Git repository hosting', + categories: ['Development'], + colors: { + end: '34495e', + start: '609926', + }, + description: `Self-hosted Git service built and maintained by a large developer community.`, + logo_url: 'gitea.svg', + name: 'Gitea', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/gitea/', + title: 'Deploy Gitea through the Linode Marketplace', + }, + ], + summary: 'Git with a cup of tea - A painless self-hosted Git service.', + website: 'https://gitea.io/', + }, + 688912: { + alt_description: 'Drag and drop website CMS.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: '4395ff', + start: '0166ff', + }, + description: `Use Kepler Builder to easily design and build sites in WordPress - no coding or design knowledge necessary.`, + logo_url: 'keplerbuilder.svg', + name: 'Kepler Builder', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/kepler/', + title: 'Deploy Kepler through the Linode Marketplace', + }, + ], + summary: 'Powerful drag & drop WordPress website builder.', + website: 'https://kepler.app/', + }, + 688914: { + alt_description: 'Desktop cloud hosting.', + alt_name: 'Virtual desktop', + categories: ['Development'], + colors: { + end: '213121', + start: '304730', + }, + description: `Access your desktop from any device with a browser to keep your desktop hosted in the cloud.`, + logo_url: 'guacamole.svg', + name: 'Guacamole', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/guacamole/', + title: 'Deploy Apache Guacamole through the Linode Marketplace', + }, + ], + summary: 'Free open source clientless remote desktop gateway.', + website: 'https://guacamole.apache.org/', + }, + 691620: { + alt_description: 'File storage alternative to Dropbox and Google Drive.', + alt_name: 'File sharing', + categories: ['Productivity'], + colors: { + end: '0168ad', + start: '3e8cc1', + }, + description: `File synchronization across multiple users’ computers and other devices to keep everyone working without interruption.`, + logo_url: 'filecloud.svg', + name: 'FileCloud', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/filecloud/', + title: 'Deploy FileCloud through the Linode Marketplace', + }, + ], + summary: 'Enterprise file sharing to manage and sync from any device.', + website: 'https://www.getfilecloud.com', + }, + 691621: { + alt_description: + 'Host multiple apps on one server and control panel, including WordPress, GitLab, and Nextcloud.', + alt_name: 'Cloud app and website control panel', + categories: ['Website'], + colors: { + end: '212121', + start: '03a9f4', + }, + description: `Turnkey solution for running apps like WordPress, Rocket.Chat, NextCloud, GitLab, and OpenVPN.`, + logo_url: 'cloudron.svg', + name: 'Cloudron', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/cloudron/', + title: 'Deploy Cloudron through the Linode Marketplace', + }, + ], + summary: + 'End-to-end deployment and automatic updates for a range of essential applications.', + website: 'https://docs.cloudron.io', + }, + 691622: { + alt_description: 'Popular website content management system.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: '3d596d', + start: '33cccc', + }, + description: `Accelerated and scalable hosting for WordPress. Includes OpenLiteSpeed, PHP, MySQL Server, WordPress, and LiteSpeed Cache.`, + logo_url: 'openlitespeedwordpress.svg', + name: 'OpenLiteSpeed WordPress', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/openlitespeed-wordpress/', + title: 'Deploy OpenLiteSpeed Wordpress through the Linode Marketplace', + }, + ], + summary: 'Blazing fast, open source alternative to LiteSpeed Web Server.', + website: 'https://openlitespeed.org/', + }, + 692092: { + alt_description: 'Limited user, hardened SSH, Fail2Ban Linode server.', + alt_name: 'Secure server tool', + categories: ['Security'], + colors: { + end: '32363b', + start: '01b058', + }, + description: `Save time on securing your Linode by deploying an instance pre-configured with some basic security best practices: limited user account access, hardened SSH, and Fail2Ban for SSH Login Protection.`, + logo_url: 'secureyourserver.svg', + name: 'Secure Your Server', + related_guides: [ + { + href: 'https://www.linode.com/docs/guides/set-up-and-secure/', + title: 'Securing your Server', + }, + ], + summary: `Harden your Linode before you deploy with the Secure Your Server One-Click App.`, + }, + 741206: { + alt_description: + 'Web hosting control panel for managing websites, including WordPress.', + alt_name: 'Web hosting control panel', + categories: ['Control Panels'], + colors: { + end: '33cccc', + start: '3d596d', + }, + description: `Reduce setup time required to host websites and applications, including popular tools like OpenLiteSpeed WordPress.`, + logo_url: 'cyberpanel.svg', + name: 'CyberPanel', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/cyberpanel/', + title: 'Deploy CyberPanel through the Linode Marketplace', + }, + ], + summary: 'Next-generation hosting control panel by OpenLiteSpeed.', + website: 'https://docs.litespeedtech.com/cloud/images/cyberpanel/', + }, + 741207: { + alt_description: 'Web interface for managing Docker containers.', + alt_name: 'Docker GUI', + categories: ['Development'], + colors: { + end: 'c4c4c4', + start: '41b883', + }, + description: `Simplify Docker deployments and make containerization easy for anyone to use. Please note: Yacht is still in alpha and is not recommended for production use.`, + logo_url: 'yacht.svg', + name: 'Yacht', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/yacht/', + title: 'Deploy Yacht through the Linode Marketplace', + }, + ], + summary: 'Intuitive web interface for managing Docker containers.', + website: 'https://github.com/SelfhostedPro/Yacht/', + }, + 741208: { + alt_description: 'Enterprise infrastructure and IT resource montioring.', + alt_name: 'Infrastructure monitoring', + categories: ['Monitoring'], + colors: { + end: '252730', + start: 'd40000', + }, + description: `Monitor, track performance and maintain availability for network servers, devices, services and other IT resources– all in one tool.`, + logo_url: 'zabbix.svg', + name: 'Zabbix', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/zabbix/', + title: 'Deploy Zabbix through the Linode Marketplace', + }, + ], + summary: 'Enterprise-class open source distributed monitoring solution.', + website: 'https://www.zabbix.com', + }, + 774829: { + alt_description: 'Host multiple sites on a Linode.', + alt_name: 'Website control panel', + categories: ['Control Panels'], + colors: { + end: 'a25c57', + start: '4c3148', + }, + description: `Host multiple sites on a single server while managing apps, firewall, databases, backups, system users, cron jobs, SSL and email– all in an intuitive interface.`, + logo_url: 'serverwand.svg', + name: 'ServerWand', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/serverwand/', + title: 'Deploy ServerWand through the Linode Marketplace', + }, + ], + summary: + 'Magical control panel for hosting websites and managing your servers.', + website: 'https://serverwand.com/', + }, + 804143: { + alt_description: 'Open source project management tool.', + alt_name: 'Ticket management project management tool', + categories: ['Productivity'], + colors: { + end: '0a0a0a', + start: '4cff4c', + }, + description: `Open source alternative to paid ticket management solutions with essential features including a streamlined task list, project and client management, and ticket prioritization.`, + logo_url: 'peppermint.svg', + name: 'Peppermint', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/peppermint/', + title: 'Deploy Peppermint through the Linode Marketplace', + }, + ], + summary: 'Simple yet scalable open source ticket management.', + website: 'https://peppermint.sh/', + }, + 804144: { + alt_description: + 'Free high-performance media streaming, including livestreaming.', + alt_name: 'Free media streaming app', + categories: ['Media and Entertainment'], + colors: { + end: '0a0a0a', + start: 'df0718', + }, + description: `Self-hosted free version to optimize and record video streaming for webinars, gaming, and more.`, + logo_url: 'antmediaserver.svg', + name: 'Ant Media Server: Community Edition', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/antmediaserver/', + title: 'Deploy Ant Media Server through the Linode Marketplace', + }, + ], + summary: 'A reliable, flexible and scalable video streaming solution.', + website: 'https://antmedia.io/', + }, + 804172: { + alt_description: 'Video and audio live streaming alternative to Twitch.', + alt_name: 'Live streaming app', + categories: ['Media and Entertainment'], + colors: { + end: '2086e1', + start: '7871ff', + }, + description: `A live streaming and chat server for use with existing popular broadcasting software.`, + logo_url: 'owncast.svg', + name: 'Owncast', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/owncast/', + title: 'Deploy Owncast through the Linode Marketplace', + }, + ], + summary: + 'The standalone “Twitch in a Box” open source streaming and chat solution.', + website: 'https://owncast.online/', + }, + 869127: { + alt_description: 'Open source course builder and education tool.', + alt_name: 'Online course CMS', + categories: ['Website'], + colors: { + end: '494949', + start: 'ff7800', + }, + description: `Robust open-source learning platform enabling online education for more than 200 million users around the world. Create personalized learning environments within a secure and integrated system built for all education levels with an intuitive interface, drag-and-drop features, and accessible documentation.`, + logo_url: 'moodle.svg', + name: 'Moodle', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/moodle/', + title: 'Deploy Moodle through the Linode Marketplace', + }, + ], + summary: + 'World’s most popular learning management system built and maintained by an active developer community.', + website: 'https://docs.moodle.org/', + }, + 869129: { + alt_description: 'Free open source control panel with a mobile app.', + alt_name: 'Free infrastructure control panel', + categories: ['Control Panels'], + colors: { + end: 'a3a3a3', + start: '20a53a', + }, + description: `Feature-rich alternative control panel for users who need critical control panel functionality but don’t need to pay for more niche premium features. aaPanel is open source and consistently maintained with weekly updates.`, + logo_url: 'aapanel.svg', + name: 'aaPanel', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/aapanel/', + title: 'Deploy aaPanel through the Linode Marketplace', + }, + ], + summary: + 'Popular open source free control panel with robust features and a mobile app.', + website: 'https://www.aapanel.com/reference.html', + }, + 869153: { + alt_description: 'Data security, data observability, data automation.', + alt_name: 'Data management', + categories: ['Development'], + colors: { + end: 'ed0181', + start: 'f89f24', + }, + description: `Popular data-to-everything platform with advanced security, observability, and automation features for machine learning and AI.`, + logo_url: 'splunk.svg', + name: 'Splunk', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/splunk/', + title: 'Deploy Splunk through the Linode Marketplace', + }, + ], + summary: + 'All-in-one database deployment, management, and monitoring system.', + website: 'https://docs.splunk.com/Documentation/Splunk', + }, + 869155: { + alt_description: + 'Image hosting and sharing alternative to Google Photos and Flickr.', + alt_name: 'Photo library and image library', + categories: ['Media and Entertainment'], + colors: { + end: '8e44ad', + start: '23a8e0', + }, + description: `Chevereto is a full-featured image sharing solution that acts as an alternative to services like Google Photos or Flickr. Optimize image hosting by using external cloud storage (like Linode’s S3-compatible Object Storage) and connect to Chevereto using API keys.`, + logo_url: 'chevereto.svg', + name: 'Chevereto', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/chevereto/', + title: 'Deploy Chevereto through the Linode Marketplace', + }, + ], + summary: + 'Self-host your own open source image library to easily upload, collaborate, and share images on your terms.', + website: 'https://v3-docs.chevereto.com/', + }, + 869156: { + alt_description: + 'File storage and sharing alternative to Dropbox and Google Drive.', + alt_name: 'File sharing', + categories: ['Productivity'], + colors: { + end: '252730', + start: '1f4c8f', + }, + description: `Securely share and collaborate Linode S3 object storage files/folders with your internal or external users such as customers, partners, vendors, etc with fine access control and a simple interface. Nirvashare easily integrates with many external identity providers such as Active Directory, GSuite, AWS SSO, KeyClock, etc.`, + logo_url: 'nirvashare.svg', + name: 'NirvaShare', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/nirvashare/', + title: 'Deploy NirvaShare through the Linode Marketplace', + }, + ], + summary: + 'Secure file sharing for better collaboration with employees, partners, vendors, and more.', + website: 'https://nirvashare.com/setup-guide/', + }, + 869158: { + alt_description: + 'SQL and NoSQL database interface and monitoring for MySQL, PostgreSQL, and more.', + alt_name: 'Database monitoring', + categories: ['Databases'], + colors: { + end: '3f434c', + start: '0589de', + }, + description: `All-in-one interface for scripting and monitoring databases, including MySQL, MariaDB, Percona, PostgreSQL, Galera Cluster and more. Easily deploy database instances, manage with an included CLI, and automate performance monitoring.`, + logo_url: 'clustercontrol.svg', + name: 'ClusterControl', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/clustercontrol/', + title: 'Deploy ClusterControl through the Linode Marketplace', + }, + ], + summary: + 'All-in-one database deployment, management, and monitoring system.', + website: 'https://docs.severalnines.com/docs/clustercontrol/', + }, + 869623: { + alt_description: 'Enterprise-ready backups tool.', + alt_name: 'Server backups management and control panel', + categories: ['Control Panels'], + colors: { + end: '1f2c38', + start: 'ff6c2c', + }, + description: `Powerful and customizable backups for several websites and data all in the same interface. JetBackup integrates with any control panel via API, and has native support for cPanel and DirectAdmin. Easily backup your data to storage you already use, including Linode’s S3-compatible Object Storage.`, + logo_url: 'jetbackup.svg', + name: 'JetBackup', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/jetbackup/', + title: 'Deploy JetBackup through the Linode Marketplace', + }, + ], + summary: + 'Advanced customizable backups to integrate with your preferred control panel.', + website: 'https://docs.jetapps.com/', + }, + 912262: { + alt_description: 'Container registry for Kubernetes.', + alt_name: 'Container registry for Kubernetes.', + categories: ['Development'], + colors: { + end: '4495d7', + start: '60b932', + }, + description: `Open source registry for images and containers. Linode recommends using Harbor with Linode Kubernetes Engine (LKE).`, + logo_url: 'harbor.svg', + name: 'Harbor', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/harbor/', + title: 'Deploy Harbor through the Linode Marketplace', + }, + ], + summary: 'Cloud native container registry for Kubernetes and more.', + website: 'https://goharbor.io/docs', + }, + 912264: { + alt_description: 'Free alternative to Slack, Microsoft Teams, and Skype.', + alt_name: 'Chat software', + categories: ['Productivity'], + colors: { + end: '030d1a', + start: 'f5445c', + }, + description: `Put data privacy first with an alternative to programs like Slack and Microsoft Teams.`, + logo_url: 'rocketchat.svg', + name: 'Rocket.Chat', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/rocketchat/', + title: 'Deploy Rocket.Chat through the Linode Marketplace', + }, + ], + summary: 'Feature-rich self-hosted chat and collaboration platform.', + website: 'https://docs.rocket.chat/', + }, + 913276: { + alt_description: + 'Security analytics for intrusion attempts and user action monitoring.', + alt_name: 'Security monitoring', + categories: ['Security'], + colors: { + end: 'ffb600', + start: '00a9e5', + }, + description: `Infrastructure monitoring solution to detect threats, intrusion attempts, unauthorized user actions, and provide security analytics.`, + logo_url: 'wazuh.svg', + name: 'Wazuh', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/wazuh/', + title: 'Deploy Wazuh through the Linode Marketplace', + }, + ], + summary: 'Free open source security monitoring solution.', + website: 'https://documentation.wazuh.com/current/index.html', + }, + 913277: { + alt_description: 'Free penetration testing tool using client-side vectors.', + alt_name: 'Penetration testing tool for security research', + categories: ['Security'], + colors: { + end: '000f21', + start: '4a80a9', + }, + description: `Test the security posture of a client or application using client-side vectors, all powered by a simple API. This project is developed solely for lawful research and penetration testing.`, + logo_url: 'beef.svg', + name: 'BeEF', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/beef/', + title: 'Deploy BeEF through the Linode Marketplace', + }, + ], + summary: + 'Browser Exploitation Framework (BeEF) is an open source web browser penetration tool.', + website: 'https://github.com/beefproject/beef', + }, + 923029: { + alt_description: 'Fast Python development with best practices.', + alt_name: 'Python framework', + categories: ['Development'], + colors: { + end: '5cbf8a', + start: '318640', + }, + description: `Simple deployment for OLS web server, Python LSAPI, and CertBot.`, + logo_url: 'openlitespeeddjango.svg', + name: 'OpenLiteSpeed Django', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/openlitespeed-django/', + title: 'Deploy OpenLiteSpeed Django through the Linode Marketplace', + }, + ], + summary: 'OLS web server with Django development framework.', + website: 'https://docs.litespeedtech.com/cloud/images/django/', + }, + 923030: { + alt_description: 'Ruby web application framework with development tools.', + alt_name: 'Ruby web application framework.', + categories: ['Development'], + colors: { + end: 'd94b7a', + start: '8e1a4a', + }, + description: `Easy setup to run Ruby apps in the cloud and take advantage of OpenLiteSpeed server features like SSL, HTTP/3 support, and RewriteRules.`, + logo_url: 'openlitespeedrails.svg', + name: 'OpenLiteSpeed Rails', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/openlitespeed-rails/', + title: 'Deploy OpenLiteSpeed Rails through the Linode Marketplace ', + }, + ], + summary: 'OLS web server with Ruby and CertBot.', + website: 'https://docs.litespeedtech.com/cloud/images/rails/', + }, + 923031: { + alt_description: + 'Versatile cross-platform JavaScript run-time (runtime) environment.', + alt_name: 'JavaScript environment', + categories: ['Development'], + colors: { + end: '33cccc', + start: '3d596d', + }, + description: `High-performance open source web server with Node and CertBot, in addition to features like HTTP/3 support and easy SSL setup.`, + logo_url: 'openlitespeednodejs.svg', + name: 'OpenLiteSpeed NodeJS', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/openlitespeed-nodejs/', + title: 'Deploy OpenLiteSpeed Node.js through the Linode Marketplace', + }, + ], + summary: 'OLS web server with NodeJS JavaScript runtime environment.', + website: 'https://docs.litespeedtech.com/cloud/images/nodejs/', + }, + 923032: { + alt_description: 'Optimized control panel server.', + alt_name: 'Web server control panel', + categories: ['Website'], + colors: { + end: '6e92c7', + start: '353785', + }, + description: `High-performance LiteSpeed web server equipped with WHM/cPanel and WHM LiteSpeed Plugin.`, + logo_url: 'litespeedcpanel.svg', + name: 'LiteSpeed cPanel', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/litespeed-cpanel/', + title: 'Deploy LiteSpeed cPanel through the Linode Marketplace', + }, + ], + summary: 'Next-generation web server with cPanel and WHM.', + website: 'https://docs.litespeedtech.com/cp/cpanel/', + }, + 923033: { + alt_description: + 'Free accounting software. QuickBooks alternative for freelancers and small businesses.', + alt_name: 'Open source accounting software', + categories: ['Productivity'], + colors: { + end: '55588b', + start: '6ea152', + }, + description: `Akaunting is a universal accounting software that helps small businesses run more efficiently. Track expenses, generate reports, manage your books, and get the other essential features to run your business from a single dashboard.`, + logo_url: 'akaunting.svg', + name: 'Akaunting', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/akaunting/', + title: 'Deploy Akaunting through the Linode Marketplace', + }, + ], + summary: + 'Free and open source accounting software you can use in your browser.', + website: 'https://akaunting.com', + }, + 923036: { + alt_description: 'Free alternative to Trello and Asana.', + alt_name: 'Kanban board project management tool', + categories: ['Productivity'], + colors: { + end: '555555', + start: 'f47564', + }, + description: `Restyaboard is an open-source alternative to Trello, but with additional smart features like offline sync, diff /revisions, nested comments, multiple view layouts, chat, and more.`, + logo_url: 'restyaboard.svg', + name: 'Restyaboard', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/restyaboard/', + title: 'Deploy Restyaboard through the Linode Marketplace', + }, + ], + summary: 'Free and open source project management tool.', + website: 'https://restya.com', + }, + 923037: { + alt_description: 'Virtual private network.', + alt_name: 'WireGuard VPN', + categories: ['Security'], + colors: { + end: '333333', + start: '1f76b7', + }, + description: `Feature-rich, self-hosted VPN based on WireGuard® protocol, plus convenient features like single sign-on, real-time bandwidth monitoring, and unlimited users/devices.`, + logo_url: 'warpspeed.svg', + name: 'WarpSpeed', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/warpspeed/', + title: 'Deploy WarpSpeed VPN through the Linode Marketplace', + }, + ], + summary: 'Secure low-latency VPN powered by WireGuard® protocol.', + website: 'https://bunker.services/products/warpspeed', + }, + 925530: { + alt_description: 'Virtual private network.', + alt_name: 'VPN', + categories: ['Security'], + colors: { + end: '1a32b1', + start: '2ec1cf', + }, + description: `UTunnel VPN is a robust cloud-based VPN server software solution. With UTunnel VPN, businesses could easily set up secure remote access to their business network. UTunnel comes with a host of business-centric features including site-to-site connectivity, single sign-on integration, 2-factor authentication, etc.`, + logo_url: 'utunnel.svg', + name: 'UTunnel VPN', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/utunnel/', + title: 'Deploy UTunnel VPN through the Linode Marketplace', + }, + ], + summary: + 'A powerful, user-friendly Virtual Private Network (VPN) server application that supports multiple VPN protocols.', + website: 'https://www.utunnel.io/linode-vpn-server.html', + }, + 925722: { + alt_description: 'Virtual private network for businesses and teams.', + alt_name: 'Enterprise VPN', + categories: ['Security'], + colors: { + end: '2e72d2', + start: '2e4153', + }, + description: `User-friendly VPN for both individual and commercial use. Choose from three pricing plans.`, + logo_url: 'pritunl.svg', + name: 'Pritunl', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/pritunl/', + title: 'Deploy Pritunl through the Linode Marketplace', + }, + ], + summary: 'Enterprise open source VPN.', + website: 'https://docs.pritunl.com/docs', + }, + 954759: { + alt_description: 'Time series database and database monitoring/metrics.', + alt_name: 'Database monitoring', + categories: ['Databases'], + colors: { + end: 'af3e56', + start: '6a1e6e', + }, + description: `VictoriaMetrics is designed to collect, store, and process real-time metrics.`, + logo_url: 'victoriametricssingle.svg', + name: 'VictoriaMetrics Single', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/victoriametrics-single/', + title: 'Deploy VictoriaMetrics Single through the Linode Marketplace', + }, + ], + summary: + 'Free and open source time series database (TSDB) and monitoring solution.', + website: 'https://victoriametrics.com/', + }, + 970522: { + alt_description: 'Popular DNS privacy sinkhole.', + alt_name: 'Network ad blocking', + categories: ['Security'], + colors: { + end: 'f60d1a', + start: '96060c', + }, + description: `Protect your network and devices from unwanted content. Avoid ads in non-browser locations with a free, lightweight, and comprehensive privacy solution you can self-host.`, + logo_url: 'pihole.svg', + name: 'Pi-hole', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/pihole/', + title: 'Deploy Pi-hole through the Linode Marketplace', + }, + ], + summary: 'Free, open source, and highly scalable DNS sinkhole.', + website: 'https://pi-hole.net/', + }, + 970523: { + alt_description: + 'Infrastructure monitoring and aler alternative to Uptime Robot.', + alt_name: 'Infrastructure monitoring', + categories: ['Monitoring'], + colors: { + end: 'baecca', + start: '67de92', + }, + description: `Uptime Kuma is self-hosted alternative to Uptime Robot. Get real-time performance insights for HTTP(s), TCP/ HTTP(s) Keyword, Ping, DNS Record, and more. Monitor everything you need in one UI dashboard, or customize how you receive alerts with a wide range of supported integrations.`, + logo_url: 'uptimekuma.svg', + name: 'Uptime Kuma', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/uptime-kuma/', + title: 'Deploy Uptime Kuma through the Linode Marketplace', + }, + ], + summary: 'Free, comprehensive, and “fancy” monitoring solution.', + website: 'https://github.com/louislam/uptime-kuma', + }, + 970559: { + alt_description: 'Markdown-based website CMS.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: 'b987cf', + start: '1a0629', + }, + description: `Build websites on a CMS that prioritizes speed and simplicity over customization and integration support. Create your content in Markdown and take advantage of powerful taxonomy to customize relationships between pages and other content.`, + logo_url: 'grav.svg', + name: 'Grav', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/grav/', + title: 'Deploy Grav through the Linode Marketplace', + }, + ], + summary: 'Modern and open source flat-file content management system.', + website: 'https://getgrav.org/', + }, + 970561: { + alt_description: + 'Versatile cross-platform JavaScript run-time (runtime) environment.', + alt_name: 'JavaScript environment', + categories: ['Development'], + colors: { + end: '333333', + start: '3d853c', + }, + description: `NodeJS is a free, open-source, and cross-platform JavaScript run-time environment that lets developers write command line tools and server-side scripts outside of a browser.`, + logo_url: 'nodejs.svg', + name: 'NodeJS', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/nodejs/', + title: 'Deploy NodeJS through the Linode Marketplace', + }, + ], + summary: + 'Popular and versatile open source JavaScript run-time environment.', + website: 'https://nodejs.org/', + }, + 971042: { + alt_description: 'Database low-code/no-code application builder.', + alt_name: 'Low-code application builder', + categories: ['Development'], + colors: { + end: 'ff8e42', + start: '995ad9', + }, + description: `Build applications without writing a single line of code. Saltcorn is a free platform that allows you to build an app with an intuitive point-and-click, drag-and-drop UI.`, + logo_url: 'saltcorn.svg', + name: 'Saltcorn', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/saltcorn/', + title: 'Deploy Saltcorn through the Linode Marketplace', + }, + ], + summary: 'Open source, no-code database application builder.', + website: 'https://saltcorn.com/', + }, + 971043: { + alt_description: + 'Open source marketing and business platform with a CRM and email marketing.', + alt_name: 'Marketing tool suite', + categories: ['Productivity'], + colors: { + end: '027e84', + start: '55354c', + }, + description: `Odoo is a free and comprehensive business app suite of tools that seamlessly integrate. Choose what you need to manage your business on a single platform, including a CRM, email marketing tools, essential project management functions, and more.`, + logo_url: 'odoo.svg', + name: 'Odoo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/odoo/', + title: 'Deploy Odoo through the Linode Marketplace', + }, + ], + summary: + 'Open source, all-in-one business app suite with more than 7 million users.', + website: 'https://www.odoo.com/', + }, + 971045: { + alt_description: 'Free alternative to Trello and Asana.', + alt_name: 'Kanban board project management tool', + categories: ['Productivity'], + colors: { + end: '1d52ad', + start: '2997f8', + }, + description: `Create boards, assign tasks, and keep projects moving with a free and robust alternative to tools like Trello and Asana.`, + logo_url: 'focalboard.svg', + name: 'Focalboard', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/focalboard/', + title: 'Deploy Focalboard through the Linode Marketplace', + }, + ], + summary: 'Free open source project management tool.', + website: 'https://www.focalboard.com/', + }, + 985364: { + alt_description: 'Monitoring server.', + alt_name: 'Server monitoring and visualization', + categories: ['Monitoring'], + colors: { + end: 'e6522c', + start: 'f9b716', + }, + description: `Free industry-standard monitoring tools that work better together. Prometheus is a powerful monitoring software tool that collects metrics from configurable data points at given intervals, evaluates rule expressions, and can trigger alerts if some condition is observed. Use Grafana to create visuals, monitor, store, and share metrics with your team to keep tabs on your infrastructure.`, + logo_url: 'prometheusgrafana.svg', + name: 'Prometheus & Grafana', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/prometheus-grafana/', + title: 'Deploy Prometheus & Grafana through the Linode Marketplace', + }, + ], + summary: 'Open source metrics and monitoring for real-time insights.', + website: 'https://prometheus.io/docs/visualization/grafana/', + }, + 985372: { + alt_description: 'Secure website CMS.', + alt_name: 'CMS: content management system', + categories: ['Website'], + colors: { + end: '5090cd', + start: 'f2a13e', + }, + description: `Free open source CMS optimized for building custom functionality and design.`, + logo_url: 'joomla.svg', + name: 'Joomla', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/joomla/', + title: 'Deploy Joomla through the Linode Marketplace', + }, + ], + summary: 'Flexible and security-focused content management system.', + website: 'https://www.joomla.org/', + }, + 985374: { + alt_description: + 'Low latency live streaming including WebRTC streaming, CMAF, and HLS.', + alt_name: 'Media streaming app', + categories: ['Media and Entertainment'], + colors: { + end: '0a0a0a', + start: 'df0718', + }, + description: `Ant Media Server makes it easy to set up a video streaming platform with ultra low latency. The Enterprise edition supports WebRTC Live Streaming in addition to CMAF and HLS streaming. Set up live restreaming to social media platforms to reach more viewers.`, + logo_url: 'antmediaserver.svg', + name: 'Ant Media Server: Enterprise Edition', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/antmediaenterpriseserver/', + title: + 'Deploy Ant Media Enterprise Edition through the Linode Marketplace', + }, + ], + summary: 'Highly scalable and feature-rich live video streaming platform.', + website: 'https://antmedia.io/', + }, + 985380: { + alt_description: + 'Digital note-taking application alternative to Evernote and OneNote.', + alt_name: 'Multimedia note-taking and digital notebook', + categories: ['Website'], + colors: { + end: '509df9', + start: '043872', + }, + description: `Capture your thoughts and securely access them from any device with a highly customizable note-taking software.`, + logo_url: 'joplin.svg', + name: 'Joplin', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/joplin/', + title: 'Deploy Joplin through the Linode Marketplace', + }, + ], + summary: 'Open source multimedia note-taking app.', + website: 'https://joplinapp.org/', + }, + 1008123: { + alt_description: 'Audio and video streaming with E2E data encryption.', + alt_name: 'Live streaming', + categories: ['Media and Entertainment'], + colors: { + end: '4d8eff', + start: '346ee0', + }, + description: `Stream live audio or video while maximizing customer engagement with advanced built-in features. Liveswitch provides real-time monitoring, audience polling, and end-to-end (E2E) data encryption.`, + logo_url: 'liveswitch.svg', + name: 'LiveSwitch', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/liveswitch/', + title: 'Deploy LiveSwitch through the Linode Marketplace', + }, + ], + summary: 'High quality and reliable interactive live streaming.', + website: 'https://www.liveswitch.io/', + }, + 1008125: { + alt_description: + 'Flexible control panel to simplify SSL certificates and push code from GitHub.', + alt_name: 'Server control panel', + categories: ['Control Panels'], + colors: { + end: '000000', + start: '059669', + }, + description: `Deploy Node.js, Ruby, Python, PHP, Go, and Java applications via an intuitive control panel. Easily set up free SSL certificates, run commands with an in-browser terminal, and push your code from Github to accelerate development.`, + logo_url: 'easypanel.svg', + name: 'Easypanel', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/easypanel/', + title: 'Deploy Easypanel through the Linode Marketplace', + }, + ], + summary: 'Modern server control panel based on Docker.', + website: 'https://easypanel.io/', + }, + 1017300: { + alt_description: + 'Security research and testing platform with hundreds of tools for reverse engineering, penetration testing, and more.', + alt_name: 'Security research', + categories: ['Security'], + colors: { + end: '2fa1bc', + start: '267ff7', + }, + description: `Kali Linux is an open source, Debian-based Linux distribution that has become an industry-standard tool for penetration testing and security audits. Kali includes hundreds of free tools for reverse engineering, penetration testing and more. Kali prioritizes simplicity, making security best practices more accessible to everyone from cybersecurity professionals to hobbyists.`, + logo_url: 'kalilinux.svg', + name: 'Kali Linux', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/kali-linux/', + title: 'Deploy Kali Linux through the Linode Marketplace', + }, + ], + summary: + 'Popular Linux distribution and tool suite for penetration testing and security research.', + website: 'https://www.kali.org/', + }, + 1037036: { + alt_description: + 'Application builder for forms, portals, admin panels, and more.', + alt_name: 'Low-code application builder', + categories: ['Development'], + colors: { + end: '000000', + start: '9981f5', + }, + description: + 'Budibase is a modern, open source low-code platform for building modern business applications in minutes. Build, design and automate business apps, such as: admin panels, forms, internal tools, client portals and more. Before Budibase, it could take developers weeks to build simple CRUD apps; with Budibase, building CRUD apps takes minutes. When self-hosting please follow best practices for securing, updating and backing up your server.', + logo_url: 'budibase.svg', + name: 'Budibase', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/budibase/', + title: 'Deploy Budibase through the Linode Marketplace', + }, + ], + summary: 'Low-code platform for building modern business applications.', + website: 'https://docs.budibase.com/docs', + }, + 1037037: { + alt_description: + 'HashiCorp containerization tool to use instead of or with Kubernetes', + alt_name: 'Container scheduler and orchestrator', + categories: ['Development'], + colors: { + end: '545556', + start: '60dea9', + }, + description: + 'A simple and flexible scheduler and orchestrator to deploy and manage containers and non-containerized applications across on-prem and clouds at scale.', + logo_url: 'nomad.svg', + name: 'HashiCorp Nomad', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/hashicorp-nomad', + title: 'Deploy HashiCorp Nomad through the Linode Marketplace', + }, + ], + summary: 'Flexible scheduling and orchestration for diverse workloads.', + website: 'https://www.nomadproject.io/docs', + }, + 1037038: { + alt_description: 'HashiCorp password and secrets management storage.', + alt_name: 'Security secrets management', + categories: ['Security'], + colors: { + end: '545556', + start: 'ffd712', + }, + description: + 'HashiCorp Vault is an open source, centralized secrets management system. It provides a secure and reliable way of storing and distributing secrets like API keys, access tokens, and passwords.', + logo_url: 'vault.svg', + name: 'HashiCorp Vault', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/hashicorp-vault', + title: 'Deploy HashiCorp Vault through the Linode Marketplace', + }, + ], + summary: 'An open source, centralized secrets management system.', + website: 'https://www.vaultproject.io/docs', + }, + 1051714: { + alt_description: 'Drag and drop website CMS.', + alt_name: 'Website builder', + categories: ['Development'], + colors: { + end: '4592ff', + start: '4592ff', + }, + description: `Microweber is an easy Drag and Drop website builder and a powerful CMS of a new generation, based on the PHP Laravel Framework.`, + logo_url: 'microweber.svg', + name: 'Microweber', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/microweber/', + title: 'Deploy Microweber through the Linode Marketplace', + }, + ], + summary: `Drag and drop CMS and website builder.`, + website: 'https://microweber.com/', + }, + 1068726: { + alt_description: 'MySQL alternative for SQL database.', + alt_name: 'SQL database', + categories: ['Databases'], + colors: { + end: '254078', + start: '326690', + }, + description: `PostgreSQL is a popular open source relational database system that provides many advanced configuration options that can help optimize your database’s performance in a production environment.`, + logo_url: 'postgresqlmarketplaceocc.svg', + name: 'PostgreSQL Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/postgresql-cluster/', + title: 'Deploy PostgreSQL Cluster through the Linode Marketplace', + }, + ], + summary: `The PostgreSQL relational database system is a powerful, scalable, and standards-compliant open-source database platform.`, + website: 'https://www.postgresql.org/', + }, + 1088136: { + alt_description: 'SQL database.', + alt_name: 'SQL database', + categories: ['Databases'], + colors: { + end: '000000', + start: 'EC7704', + }, + description: `Galera provides a performant multi-master/active-active database solution with synchronous replication, to achieve high availability.`, + logo_url: 'galeramarketplaceocc.svg', + name: 'Galera Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/galera-cluster/', + title: 'Deploy Galera Cluster through the Linode Marketplace', + }, + ], + summary: `Multi-master MariaDB database cluster.`, + website: 'https://galeracluster.com/', + }, + 1096122: { + alt_description: 'Open source Twitter alternative.', + alt_name: 'Open source social media', + categories: ['Media and Entertainment'], + colors: { + end: '563ACC', + start: '6364FF', + }, + description: `Mastodon is an open-source and decentralized micro-blogging platform, supporting federation and public access to the server.`, + logo_url: 'mastodon.svg', + name: 'Mastodon', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mastodon/', + title: 'Deploy Mastodon through the Linode Marketplace', + }, + ], + summary: + 'Mastodon is an open-source and decentralized micro-blogging platform.', + website: 'https://docs.joinmastodon.org/', + }, + 1102900: { + alt_description: + 'Open-source workflow management platform for data engineering pipelines.', + alt_name: 'Workflow management platform', + categories: ['Development'], + colors: { + end: 'E43921', + start: '00C7D4', + }, + description: `Programmatically author, schedule, and monitor workflows with a Python-based tool. Airflow provides full insight into the status and logs of your tasks, all in a modern web application.`, + logo_url: 'apacheairflow.svg', + name: 'Apache Airflow', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/apache-airflow/', + title: 'Deploy Apache Airflow through the Linode Marketplace', + }, + ], + summary: + 'Open source workflow management platform for data engineering pipelines.', + website: 'https://airflow.apache.org/', + }, + 1102902: { + alt_description: 'Web Application Firewall.', + alt_name: 'Community WAF', + categories: ['Security'], + colors: { + end: '00C1A9', + start: '22324F', + }, + description: `Harden your web applications and APIs against OWASP Top 10 attacks. Haltdos makes it easy to manage WAF settings and review logs in an intuitive web-based GUI.`, + logo_url: 'haltdos.svg', + name: 'HaltDOS Community WAF', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/haltdos-community-waf/', + title: 'Deploy Haltdos Community WAF through the Linode Marketplace', + }, + ], + summary: 'User-friendly web application firewall.', + website: 'https://www.haltdos.com/', + }, + 1102904: { + alt_description: + 'A simple SQL interface to store and search unstructured data.', + alt_name: 'SuperinsightDB', + categories: ['Databases'], + colors: { + end: 'C54349', + start: 'E6645F', + }, + description: `Superinsight provides a simple SQL interface to store and search unstructured data. Superinsight is built on top of PostgreSQL to take advantage of powerful extensions and features, plus the ability to run machine learning operations using SQL statements.`, + logo_url: 'superinsight.svg', + name: 'Superinsight', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/superinsight/', + title: 'Deploy Superinsight through the Linode Marketplace', + }, + ], + summary: 'Relational database for unstructured data.', + website: 'https://www.superinsight.ai/', + }, + 1102905: { + alt_description: + 'No-code platform for Kubernetes developers and operators.', + alt_name: 'Go Paddle', + categories: ['Development'], + colors: { + end: '252930', + start: '3a5bfd', + }, + description: `Provision multicloud clusters, containerize applications, and build DevOps pipelines. Gopaddle’s suite of templates and integrations helps eliminate manual errors and automate Kubernetes application releases.`, + logo_url: 'gopaddle.svg', + name: 'Gopaddle', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/gopaddle/', + title: 'Deploy Gopaddle through the Linode Marketplace', + }, + ], + summary: + 'Simple low-code platform for Kubernetes developers and operators.', + website: 'https://gopaddle.io/', + }, + 1102906: { + alt_description: 'Password Manager', + alt_name: 'Pass Key', + categories: ['Security'], + colors: { + end: '3A5EFF', + start: '709cff', + }, + description: `Self-host a password manager designed to simplify and secure your digital life. Passky is a streamlined version of paid password managers designed for everyone to use.`, + logo_url: 'passky.svg', + name: 'Passky', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/passky/', + title: 'Deploy Passky through the Linode Marketplace', + }, + ], + summary: 'Simple open source password manager.', + website: 'https://passky.org/', + }, + 1102907: { + alt_description: 'Office Suite', + alt_name: 'Office Docs', + categories: ['Productivity'], + colors: { + end: 'ff6f3d', + start: 'ffa85b', + }, + description: `Create and collaborate on text documents, spreadsheets, and presentations compatible with popular file types including .docx, .xlsx, and more. Additional features include real-time editing, paragraph locking while co-editing, and version history.`, + logo_url: 'onlyoffice.svg', + name: 'ONLYOFFICE Docs', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/onlyoffice/', + title: 'Deploy ONLYOFFICE Docs through the Linode Marketplace', + }, + ], + summary: 'Open source comprehensive office suite.', + website: 'https://www.onlyoffice.com/', + }, + 1132204: { + alt_description: 'In-memory caching database.', + alt_name: 'High performance database', + categories: ['Databases'], + colors: { + end: '722b20', + start: '222222', + }, + description: `Redis® is an open-source, in-memory, data-structure store, with the optional ability to write and persist data to a disk, which can be used as a key-value database, cache, and message broker. Redis® features built-in transactions, replication, and support for a variety of data structures such as strings, hashes, lists, sets, and others.

*Redis is a registered trademark of Redis Ltd. Any rights therein are reserved to Redis Ltd. Any use by Akamai Technologies is for referential purposes only and does not indicate any sponsorship, endorsement or affiliation between Redis and Akamai Technologies.`, + logo_url: 'redissentinelmarketplaceocc.svg', + name: 'Marketplace App for Redis® Sentinel Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/redis-cluster/', + title: + 'Deploy Redis® Sentinel Cluster through the Linode Marketplace', + }, + ], + summary: + 'Flexible, in-memory, NoSQL database service supported in many different coding languages.', + website: 'https://redis.io/', + }, + 1160816: { + alt_description: 'Self-hosted file sharing and collaboration platform.', + alt_name: 'Collabrative file sharing', + categories: ['Productivity'], + colors: { + end: '041e42', + start: '041e42', + }, + description: `LAMP-stack-based server application that allows you to access your files from anywhere in a secure way.`, + logo_url: 'owncloud.svg', + name: 'ownCloud', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/owncloud/', + title: 'Deploy ownCloud through the Linode Marketplace', + }, + ], + summary: + 'Dropbox and OneDrive alternative that lets you remain in control of your files.', + website: 'https://doc.owncloud.com/docs/next/', + }, + 1160820: { + alt_description: + 'A self-hosted backend-as-a-service platform that provides developers with all the core APIs required to build any application.', + alt_name: 'Self-hosted backend-as-a-service', + categories: ['Development'], + colors: { + end: 'f02e65', + start: 'f02e65', + }, + description: `A self-hosted Firebase alternative for web, mobile & Flutter developers.`, + logo_url: 'appwrite.svg', + name: 'Appwrite', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/appwrite/', + title: 'Deploy Appwrite through the Linode Marketplace', + }, + ], + summary: + 'Appwrite is an open-source, cross-platform and technology-agnostic alternative to Firebase, providing all the core APIs necessary for web, mobile and Flutter development.', + website: 'https://appwrite.io/', + }, + 1177225: { + alt_description: 'A safe home for all your data.', + alt_name: + 'Spreadsheet style interface with the power of a relational database.', + categories: ['Productivity'], + colors: { + end: 'FF8000', + start: 'FF8000', + }, + description: `Self-hosted database for a variety of management projects.`, + logo_url: 'seatable.svg', + name: 'Seatable', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/seatable/', + title: 'Deploy Seatable through the Linode Marketplace', + }, + ], + summary: + 'Collaborative web interface for data backed project and process management.', + website: 'https://seatable.io/docs/?lang=auto', + }, + 1177605: { + alt_description: + 'Retool open-source alternative, with low-code UI components.', + alt_name: 'Low-code development platform', + categories: ['Security'], + colors: { + end: 'FF58BE', + start: '654AEC', + }, + description: + 'Illa Builder is a Retool open-source alternative, with low-code UI components for self-hosting the development of internal tools.', + logo_url: 'illabuilder.svg', + name: 'Illa Builder', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/illa-builder', + title: 'Deploy Illa Builder through the Linode Marketplace', + }, + ], + summary: 'An open-source, low-code development platform.', + website: 'https://github.com/illacloud/illa-builder', + }, + 1226544: { + alt_description: + 'HashiCorp containerization tool to use instead of or with Kubernetes', + alt_name: 'Container scheduler and orchestrator', + categories: ['Development'], + colors: { + end: '545556', + start: '60dea9', + }, + description: + 'A simple and flexible scheduler and orchestrator to deploy and manage containers and non-containerized applications across on-prem and clouds at scale.', + logo_url: 'nomad.svg', + name: 'HashiCorp Nomad Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/hashicorp-nomad-cluster', + title: 'Deploy HashiCorp Nomad Cluster through the Linode Marketplace', + }, + ], + summary: 'Flexible scheduling and orchestration for diverse workloads.', + website: 'https://www.nomadproject.io/docs', + }, + 1226545: { + alt_description: + 'HashiCorp Nomad clients for horizontally scaling a Nomad One-Click Cluster', + alt_name: 'Container scheduler and orchestrator', + categories: ['Development'], + colors: { + end: '545556', + start: '60dea9', + }, + description: + 'A simple deployment of multiple clients to horizontally scale an existing Nomad One-Click Cluster.', + logo_url: 'nomad.svg', + name: 'HashiCorp Nomad Clients Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/hashicorp-nomad-clients-cluster', + title: + 'Deploy HashiCorp Nomad Clients Cluster through the Linode Marketplace', + }, + ], + summary: 'Flexible scheduling and orchestration for diverse workloads.', + website: 'https://www.nomadproject.io/docs', + }, + 1243759: { + alt_description: 'FFmpeg encoder plugins.', + alt_name: 'Premium video encoding', + categories: ['Media and Entertainment'], + colors: { + end: '041125', + start: '6DBA98', + }, + description: `MainConcept FFmpeg Plugins Demo is suited for both VOD and live production workflows, with advanced features such as Hybrid GPU acceleration and xHE-AAC audio format.`, + logo_url: 'mainconcept.svg', + name: 'MainConcept FFmpeg Plugins Demo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-ffmpeg-plugins-demo/', + title: + 'Deploy MainConcept FFmpeg Plugins Demo through the Linode Marketplace', + }, + ], + summary: + 'MainConcept FFmpeg Plugins Demo contains advanced video encoding tools.', + website: 'https://www.mainconcept.com/ffmpeg', + }, + 1243760: { + alt_description: 'Live video encoding engine.', + alt_name: 'Real time video encoding', + categories: ['Media and Entertainment'], + colors: { + end: '041125', + start: '6DBA98', + }, + description: `MainConcept Live Encoder Demo is a powerful all-in-one encoding engine designed to simplify common broadcast and OTT video workflows.`, + logo_url: 'mainconcept.svg', + name: 'MainConcept Live Encoder Demo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-live-encoder-demo/', + title: + 'Deploy MainConcept Live Encoder Demo through the Linode Marketplace', + }, + ], + summary: 'MainConcept Live Encoder is a real time video encoding engine.', + website: 'https://www.mainconcept.com/live-encoder', + }, + 1243762: { + alt_description: 'Panasonic camera format encoder.', + alt_name: 'Media encoding into professional file formats.', + categories: ['Media and Entertainment'], + colors: { + end: '041125', + start: '6DBA98', + }, + description: `MainConcept P2 AVC ULTRA Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Panasonic camera formats like P2 AVC-Intra, P2 AVC LongG and AVC-intra RP2027.v1 and AAC High Efficiency v2 formats into an MP4 container.`, + logo_url: 'mainconcept.svg', + name: 'MainConcept P2 AVC ULTRA Transcoder Demo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-p2-avc-ultra-demo/', + title: + 'Deploy MainConcept P2 AVC ULTRA Transcoder Demo through the Linode Marketplace', + }, + ], + summary: + 'MainConcept P2 AVC ULTRA Transcoder is a Docker container for file-based transcoding of media files into professional Panasonic camera formats.', + website: 'https://www.mainconcept.com/transcoders', + }, + 1243763: { + alt_description: 'Sony camera format encoder.', + alt_name: 'Media encoding into professional file formats.', + categories: ['Media and Entertainment'], + colors: { + end: '041125', + start: '6DBA98', + }, + description: `MainConcept XAVC Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Sony camera formats like XAVC-Intra, XAVC Long GOP and XAVC-S.`, + logo_url: 'mainconcept.svg', + name: 'MainConcept XAVC Transcoder Demo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-xavc-transcoder-demo/', + title: + 'Deploy MainConcept XAVC Transcoder Demo through the Linode Marketplace', + }, + ], + summary: + 'MainConcept XAVC Transcoder is a Docker container for file-based transcoding of media files into professional Sony camera formats.', + website: 'https://www.mainconcept.com/transcoders', + }, + 1243764: { + alt_description: 'Sony XDCAM format encoder.', + alt_name: 'Media encoding into professional file formats.', + categories: ['Media and Entertainment'], + colors: { + end: '041125', + start: '6DBA98', + }, + description: `MainConcept XDCAM Transcoder Demo is an optimized Docker container for file-based transcoding of media files into professional Sony camera formats like XDCAM HD, XDCAM EX, XDCAM IMX and DVCAM (XDCAM DV).`, + logo_url: 'mainconcept.svg', + name: 'MainConcept XDCAM Transcoder Demo', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/mainconcept-xdcam-transcoder-demo/', + title: + 'Deploy MainConcept XDCAM Transcoder Demo through the Linode Marketplace', + }, + ], + summary: + 'MainConcept XDCAM Transcoder is a Docker container for file-based transcoding of media files into professional Sony camera formats.', + website: 'https://www.mainconcept.com/transcoders', + }, + 1243780: { + alt_description: 'A private by design messaging platform.', + alt_name: 'Anonymous messaging platform.', + categories: ['Productivity'], + colors: { + end: '70f0f9', + start: '11182f', + }, + description: `SimpleX Chat - The first messaging platform that has no user identifiers of any kind - 100% private by design. SMP server is the relay server used to pass messages in SimpleX network. XFTP is a new file transfer protocol focussed on meta-data protection. This One-Click APP will deploy both SMP and XFTP servers.`, + logo_url: 'simplexchat.svg', + name: 'SimpleX Chat', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/simplex/', + title: 'Deploy SimpleX chat through the Linode Marketplace', + }, + ], + summary: 'Private by design messaging server.', + website: 'https://simplex.chat', + }, + 1298017: { + alt_description: 'Data science notebook.', + alt_name: 'Data science and machine learning development environment.', + categories: ['Productivity'], + colors: { + end: '9e9e9e', + start: 'f37626', + }, + description: + 'JupyterLab is a cutting-edge web-based, interactive development environment, geared towards data science, machine learning and other scientific computing workflows.', + logo_url: 'jupyter.svg', + name: 'JupyterLab', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/jupyterlab/', + title: 'Deploy JupyterLab through the Linode Marketplace', + }, + ], + summary: 'Data science development environment.', + website: 'https://jupyter.org', + }, + 1308539: { + alt_description: `Microservice centeric stream processing.`, + alt_name: 'Microservice messaging bus', + categories: ['Development'], + colors: { + end: '000000', + start: '0086FF', + }, + description: + 'NATS is a distributed PubSub technology that enables applications to securely communicate across any combination of cloud vendors, on-premise, edge, web and mobile, and devices.', + logo_url: 'nats.svg', + name: 'NATS Single Node', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/nats-single-node/', + title: 'Deploy NATS single node through the Linode Marketplace', + }, + ], + summary: 'Cloud native application messaging service.', + website: 'https://nats.io', + }, + 1329430: { + alt_description: 'Password Manager', + alt_name: 'Passbolt', + categories: ['Security'], + colors: { + end: 'D40101', + start: '171717', + }, + description: `Passbolt is an open-source password manager designed for teams and businesses. It allows users to securely store, share and manage passwords.`, + logo_url: 'passbolt.svg', + name: 'Passbolt', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/passbolt/', + title: 'Deploy Passbolt through the Linode Marketplace', + }, + ], + summary: 'Open-source password manager for teams and businesses.', + website: 'https://www.passbolt.com/', + }, + 1329462: { + alt_description: + 'LinuxGSM is a command line utility that simplifies self-hosting multiplayer game servers.', + alt_name: 'Multiplayer Game Servers', + categories: ['Games'], + colors: { + end: 'F6BD0C', + start: '000000', + }, + description: `Self hosted multiplayer game servers.`, + logo_url: 'linuxgsm.svg', + name: 'LinuxGSM', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/linuxgsm/', + title: 'Deploy LinuxGSM through the Linode Marketplace', + }, + ], + summary: 'Simple command line multiplayer game servers.', + website: 'https://docs.linuxgsm.com', + }, + 1350733: { + alt_description: + 'Open source video conferencing cluster, alternative to Zoom.', + alt_name: 'Video chat and video conferencing cluster', + categories: ['Media and Entertainment'], + colors: { + end: '949699', + start: '1d76ba', + }, + description: `Secure, stable, and free alternative to popular video conferencing services. This app deploys four networked Jitsi nodes.`, + logo_url: 'jitsi.svg', + name: 'Jitsi Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/jitsi-cluster/', + title: 'Deploy Jitsi Cluster through the Linode Marketplace', + }, + ], + summary: 'Free, open source video conferencing and communication platform.', + website: 'https://jitsi.org/', + }, + 1350783: { + alt_description: 'Open source, highly available, shared filesystem.', + alt_name: 'GlusterFS', + categories: ['Development'], + colors: { + end: '784900', + start: 'D4AC5C', + }, + description: + 'GlusterFS is an open source, software scalable network filesystem. This app deploys three GlusterFS servers and three GlusterFS clients.', + logo_url: 'glusterfs.svg', + name: 'GlusterFS Cluster', + related_guides: [ + { + href: + 'https://www.linode.com/docs/products/tools/marketplace/guides/glusterfs-cluster/', + title: 'Deploy GlusterFS Cluster through the Linode Marketplace', + }, + ], + summary: 'Open source network filesystem.', + website: 'https://www.gluster.org/', + }, +}; From 0b0cf9826922db1f970c16c9dda413882f4099c0 Mon Sep 17 00:00:00 2001 From: Azure-akamai Date: Wed, 22 May 2024 11:23:48 -0400 Subject: [PATCH 012/163] test: [M3-7783] Add cypress assertion and test for placement group deletion error handling (#10493) * add cypress assertion for PG deletion error handling * update cypress assertion * update cypress assertion * update cypress assertion * add a new cypress test * Add new tests/assertions to check error handling in delete-placement-groups and skip tests due to bug ticket M3-8112 * add a comment * Added changeset: Add cypress assertion and test for placement group deletion error handling * revert change * unskip tests because #10486 has merged --- packages/api-v4/src/images/images.ts | 7 +- .../pr-10493-tests-1716312638686.md | 5 + .../delete-placement-groups.spec.ts | 307 +++++++++++++++++- .../support/intercepts/placement-groups.ts | 23 +- 4 files changed, 335 insertions(+), 7 deletions(-) create mode 100644 packages/manager/.changeset/pr-10493-tests-1716312638686.md diff --git a/packages/api-v4/src/images/images.ts b/packages/api-v4/src/images/images.ts index f6acfd3418a..9c9984bdbd1 100644 --- a/packages/api-v4/src/images/images.ts +++ b/packages/api-v4/src/images/images.ts @@ -12,7 +12,12 @@ import Request, { setXFilter, } from '../request'; import { Filter, Params, ResourcePage as Page } from '../types'; -import { CreateImagePayload, Image, ImageUploadPayload, UploadImageResponse } from './types'; +import { + CreateImagePayload, + Image, + ImageUploadPayload, + UploadImageResponse, +} from './types'; /** * Get information about a single Image. diff --git a/packages/manager/.changeset/pr-10493-tests-1716312638686.md b/packages/manager/.changeset/pr-10493-tests-1716312638686.md new file mode 100644 index 00000000000..e0687a7fdbf --- /dev/null +++ b/packages/manager/.changeset/pr-10493-tests-1716312638686.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add cypress assertion and test for placement group deletion error handling ([#10493](https://github.com/linode/manager/pull/10493)) diff --git a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts index e99a3ffecf7..c1dfd05220a 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/delete-placement-groups.spec.ts @@ -13,6 +13,8 @@ import { mockDeletePlacementGroup, mockGetPlacementGroups, mockUnassignPlacementGroupLinodes, + mockDeletePlacementGroupError, + mockUnassignPlacementGroupLinodesError, } from 'support/intercepts/placement-groups'; import { accountFactory, @@ -42,6 +44,9 @@ const unassignWarning = const emptyStateMessage = 'Control the physical placement or distribution of Linode instances within a data center or availability zone.'; +// Error message that when an unexpected error occurs. +const PlacementGroupErrorMessage = 'An unknown error has occurred.'; + describe('Placement Group deletion', () => { beforeEach(() => { // TODO Remove feature flag mocks when `placementGroups` flag is retired. @@ -60,8 +65,9 @@ describe('Placement Group deletion', () => { * - Confirms that user is not warned or prompted to unassign Linodes when none are assigned. * - Confirms that UI automatically updates to reflect deleted Placement Group. * - Confirms that landing page reverts to its empty state when last Placement Group is deleted. + * - Confirms that user can retry and continue with deletion when unexpected error happens. */ - it('can delete without Linodes assigned', () => { + it('can delete without Linodes assigned when unexpected error show up and retry', () => { const mockPlacementGroupRegion = chooseRegion(); const mockPlacementGroup = placementGroupFactory.build({ id: randomNumber(), @@ -76,7 +82,6 @@ describe('Placement Group deletion', () => { cy.visitWithLogin('/placement-groups'); cy.wait('@getPlacementGroups'); - // Click "Delete" button next to the mock Placement Group. cy.findByText(mockPlacementGroup.label) .should('be.visible') .closest('tr') @@ -88,6 +93,30 @@ describe('Placement Group deletion', () => { .click(); }); + // Click "Delete" button next to the mock Placement Group, mock an HTTP 500 error and confirm UI displays the message. + mockDeletePlacementGroupError( + mockPlacementGroup.id, + PlacementGroupErrorMessage + ).as('deletePlacementGroupError'); + + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Placement Group').type(mockPlacementGroup.label); + + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@deletePlacementGroupError'); + cy.findByText(PlacementGroupErrorMessage).should('be.visible'); + }); + + // Click "Delete" button next to the mock Placement Group, + // mock a successful response and confirm that Cloud mockDeletePlacementGroup(mockPlacementGroup.id).as('deletePlacementGroup'); mockGetPlacementGroups([]).as('getPlacementGroups'); @@ -99,8 +128,6 @@ describe('Placement Group deletion', () => { cy.findByText(deletionWarning).should('be.visible'); cy.findByText(unassignWarning).should('not.exist'); - cy.findByLabelText('Placement Group').type(mockPlacementGroup.label); - ui.button .findByTitle('Delete') .should('be.visible') @@ -122,8 +149,9 @@ describe('Placement Group deletion', () => { * - Confirms that user is prompted to unassign Linodes before being able to proceed with deletion. * - Confirms that UI automatically updates to reflect unassigned Linodes during deletion. * - Confirms that UI automatically updates to reflect deleted Placement Group. + * - Confirms that user can retry and continue with unassignment when unexpected error happens. */ - it('can delete with Linodes assigned', () => { + it('can delete with Linodes assigned when unexpected error show up and retry', () => { const mockPlacementGroupRegion = chooseRegion(); // Linodes that are assigned to the Placement Group being deleted. @@ -176,6 +204,36 @@ describe('Placement Group deletion', () => { .click(); }); + // Click "Delete" button next to the mock Placement Group, mock an HTTP 500 error and confirm UI displays the message. + mockUnassignPlacementGroupLinodesError( + mockPlacementGroup.id, + PlacementGroupErrorMessage + ).as('UnassignPlacementGroupError'); + + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + cy.get('[data-qa-selection-list]').within(() => { + // Select the first Linode to unassign + const mockLinodeToUnassign = mockPlacementGroupLinodes[0]; + + cy.findByText(mockLinodeToUnassign.label) + .should('be.visible') + .closest('li') + .within(() => { + ui.button + .findByTitle('Unassign') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + cy.wait('@UnassignPlacementGroupError'); + cy.findByText(PlacementGroupErrorMessage).should('be.visible'); + }); + // Confirm deletion warning appears and that form cannot be submitted // while Linodes are assigned. ui.dialog @@ -256,4 +314,243 @@ describe('Placement Group deletion', () => { cy.findByText(mockPlacementGroup.label).should('not.exist'); cy.findByText(secondMockPlacementGroup.label).should('be.visible'); }); + + /* + * - Confirms UI flow for Placement Group deletion from landing page using mock API data. + * - Confirms that user is not warned or prompted to unassign Linodes when none are assigned. + * - Confirms that UI automatically updates to reflect deleted Placement Group. + * - Confirms that landing page reverts to its empty state when last Placement Group is deleted. + * - Confirms that user can close and reopen the dialog when unexpected error happens. + */ + it('can delete without Linodes assigned when unexpected error show up and reopen the dialog', () => { + const mockPlacementGroupRegion = chooseRegion(); + const mockPlacementGroup = placementGroupFactory.build({ + id: randomNumber(), + label: randomLabel(), + members: [], + region: mockPlacementGroupRegion.id, + is_compliant: true, + }); + + mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); + + cy.visitWithLogin('/placement-groups'); + cy.wait('@getPlacementGroups'); + + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Click "Delete" button next to the mock Placement Group, mock an HTTP 500 error and confirm UI displays the message. + mockDeletePlacementGroupError( + mockPlacementGroup.id, + PlacementGroupErrorMessage + ).as('deletePlacementGroupError'); + + // The dialog can be closed after an unexpect error show up + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Placement Group').type(mockPlacementGroup.label); + + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@deletePlacementGroupError'); + cy.findByText(PlacementGroupErrorMessage).should('be.visible'); + + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + cy.findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`).should( + 'not.exist' + ); + + // Click "Delete" button next to the mock Placement Group, + // mock a successful response and confirm that Cloud + mockDeletePlacementGroup(mockPlacementGroup.id).as('deletePlacementGroup'); + + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + mockGetPlacementGroups([]).as('getPlacementGroups'); + + // Confirm deletion warning appears, complete Type-to-Confirm, and submit confirmation. + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + // ensure error message not exist when reopening the dialog + cy.findByText(PlacementGroupErrorMessage).should('not.exist'); + + cy.findByLabelText('Placement Group').type(mockPlacementGroup.label); + + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + /* + * - Confirms UI flow for Placement Group deletion from landing page using mock API data. + * - Confirms deletion flow when Placement Group has one or more Linodes assigned to it. + * - Confirms that user is prompted to unassign Linodes before being able to proceed with deletion. + * - Confirms that user can close and reopen the dialog when unexpected error happens. + */ + it('can unassign Linode when unexpected error show up and reopen the dialog', () => { + const mockPlacementGroupRegion = chooseRegion(); + + // Linodes that are assigned to the Placement Group being deleted. + const mockPlacementGroupLinodes = buildArray(3, () => + linodeFactory.build({ + label: randomLabel(), + id: randomNumber(), + region: mockPlacementGroupRegion.id, + }) + ); + + // Placement Group that will be deleted. + const mockPlacementGroup = placementGroupFactory.build({ + id: randomNumber(), + label: randomLabel(), + members: mockPlacementGroupLinodes.map((linode) => ({ + linode_id: linode.id, + is_compliant: true, + })), + region: mockPlacementGroupRegion.id, + is_compliant: true, + }); + + // Second unrelated Placement Group to verify landing page content after deletion. + const secondMockPlacementGroup = placementGroupFactory.build({ + id: randomNumber(), + label: randomLabel(), + members: [], + region: mockPlacementGroupRegion.id, + is_compliant: true, + }); + + mockGetLinodes(mockPlacementGroupLinodes).as('getLinodes'); + mockGetPlacementGroups([mockPlacementGroup, secondMockPlacementGroup]).as( + 'getPlacementGroups' + ); + + cy.visitWithLogin('/placement-groups'); + cy.wait('@getPlacementGroups'); + + // Click "Delete" button next to the mock Placement Group. + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Click "Delete" button next to the mock Placement Group, mock an HTTP 500 error and confirm UI displays the message. + mockUnassignPlacementGroupLinodesError( + mockPlacementGroup.id, + PlacementGroupErrorMessage + ).as('UnassignPlacementGroupError'); + + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + cy.get('[data-qa-selection-list]').within(() => { + // Select the first Linode to unassign + const mockLinodeToUnassign = mockPlacementGroupLinodes[0]; + + cy.findByText(mockLinodeToUnassign.label) + .should('be.visible') + .closest('li') + .within(() => { + ui.button + .findByTitle('Unassign') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + cy.wait('@UnassignPlacementGroupError'); + cy.findByText(PlacementGroupErrorMessage).should('be.visible'); + + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`).should( + 'not.exist' + ); + + // Click "Delete" button next to the mock Placement Group to reopen the dialog + cy.findByText(mockPlacementGroup.label) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm deletion warning appears and that form cannot be submitted + // while Linodes are assigned. + ui.dialog + .findByTitle(`Delete Placement Group ${mockPlacementGroup.label}`) + .should('be.visible') + .within(() => { + // ensure error message not exist when reopening the dialog + cy.findByText(PlacementGroupErrorMessage).should('not.exist'); + + // Unassign each Linode. + cy.get('[data-qa-selection-list]').within(() => { + // Select the first Linode to unassign + const mockLinodeToUnassign = mockPlacementGroupLinodes[0]; + + cy.findByText(mockLinodeToUnassign.label) + .should('be.visible') + .closest('li') + .within(() => { + ui.button + .findByTitle('Unassign') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + }); + }); }); diff --git a/packages/manager/cypress/support/intercepts/placement-groups.ts b/packages/manager/cypress/support/intercepts/placement-groups.ts index 73c1f92d6b7..4511a188885 100644 --- a/packages/manager/cypress/support/intercepts/placement-groups.ts +++ b/packages/manager/cypress/support/intercepts/placement-groups.ts @@ -1,6 +1,5 @@ import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; - import type { PlacementGroup } from '@linode/api-v4'; import { makeResponse } from 'support/util/response'; import { makeErrorResponse } from 'support/util/errors'; @@ -154,3 +153,25 @@ export const mockUnassignPlacementGroupLinodesError = ( makeErrorResponse(errorMessage, errorCode) ); }; + +/** + * Intercepts POST request to delete a Placement Group and mocks an HTTP error response. + * + * By default, a 500 response is mocked. + * + * @param errorMessage - Optional error message with which to mock response. + * @param errorCode - Optional error code with which to mock response. Default is `500`. + * + * @returns Cypress chainable. + */ +export const mockDeletePlacementGroupError = ( + placementGroupId: number, + errorMessage: string = 'An error has occurred', + errorCode: number = 500 +): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher(`placement/groups/${placementGroupId}`), + makeErrorResponse(errorMessage, errorCode) + ); +}; From b774f629a81b39445fd7cbd2fa138ce8b9669190 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Wed, 22 May 2024 08:56:43 -0700 Subject: [PATCH 013/163] Disable form event firing temporarily (#10498) --- packages/manager/src/utilities/analytics/utils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/manager/src/utilities/analytics/utils.ts b/packages/manager/src/utilities/analytics/utils.ts index 991e605d3cd..bc0b994fe6b 100644 --- a/packages/manager/src/utilities/analytics/utils.ts +++ b/packages/manager/src/utilities/analytics/utils.ts @@ -57,8 +57,7 @@ export const sendFormEvent = ( } else if (eventType === 'formError' && 'formError' in eventPayload) { formEventPayload['formError'] = eventPayload.formError.replace(/\|/g, ''); } - - window._satellite.track(eventType, formEventPayload); + // window._satellite.track(eventType, formEventPayload); } }; From 0d03587cd7c34c1ec230d19760abbfe8635ea067 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Wed, 22 May 2024 12:18:56 -0400 Subject: [PATCH 014/163] refactor: [M3-8125] Remove and Prevent tooltip disableInteractive (#10501) * remove and prevent tooltip disableInteractive * Added changeset: Remove and Prevent tooltip disableInteractive * feedback @mjac0bs --- .../pr-10501-tech-stories-1716324364842.md | 5 +++++ .../src/components/AccessPanel/AccessPanel.tsx | 3 --- .../components/BackupStatus/BackupStatus.tsx | 1 - packages/manager/src/components/Checkbox.tsx | 17 +++-------------- .../components/PasswordInput/PasswordInput.tsx | 2 -- packages/manager/src/components/TextField.tsx | 3 --- .../src/components/Toggle/Toggle.stories.tsx | 6 ------ .../src/components/Toggle/Toggle.test.tsx | 2 +- .../manager/src/components/Toggle/Toggle.tsx | 14 ++------------ packages/manager/src/components/TooltipIcon.tsx | 12 ++++-------- .../DatabaseSettings/MaintenanceWindow.tsx | 1 - .../manager/src/features/Images/ImageUpload.tsx | 1 - .../Images/ImagesCreate/CreateImageTab.tsx | 1 - .../UserDefinedFields/UserDefinedFieldInput.tsx | 1 - .../LinodeCreatev2/UserData/UserDataHeading.tsx | 1 - .../UserDataAccordionHeading.tsx | 1 - .../LinodeConfigs/LinodeConfigDialog.tsx | 2 -- .../LinodeResizeUnifiedMigrationPanel.tsx | 2 -- .../LinodesLanding/LinodeRow/LinodeRow.tsx | 1 - .../Linodes/PublicIpsUnassignedTooltip.tsx | 1 - .../Profile/DisplaySettings/DisplaySettings.tsx | 1 - .../FieldTypes/UserDefinedText.tsx | 11 +---------- .../UserDefinedFieldsPanel.tsx | 1 - .../features/VPCs/VPCDetail/AssignIPRanges.tsx | 1 - .../features/VPCs/VPCDetail/SubnetLinodeRow.tsx | 1 - 25 files changed, 16 insertions(+), 76 deletions(-) create mode 100644 packages/manager/.changeset/pr-10501-tech-stories-1716324364842.md diff --git a/packages/manager/.changeset/pr-10501-tech-stories-1716324364842.md b/packages/manager/.changeset/pr-10501-tech-stories-1716324364842.md new file mode 100644 index 00000000000..e97bd3728cf --- /dev/null +++ b/packages/manager/.changeset/pr-10501-tech-stories-1716324364842.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Make all tooltips Interactive and prevent `disableInteractive` for future usage ([#10501](https://github.com/linode/manager/pull/10501)) diff --git a/packages/manager/src/components/AccessPanel/AccessPanel.tsx b/packages/manager/src/components/AccessPanel/AccessPanel.tsx index 3f68efe5304..d4c0613adbd 100644 --- a/packages/manager/src/components/AccessPanel/AccessPanel.tsx +++ b/packages/manager/src/components/AccessPanel/AccessPanel.tsx @@ -56,7 +56,6 @@ interface Props { setAuthorizedUsers?: (usernames: string[]) => void; small?: boolean; toggleDiskEncryptionEnabled?: () => void; - tooltipInteractive?: boolean; } export const AccessPanel = (props: Props) => { @@ -79,7 +78,6 @@ export const AccessPanel = (props: Props) => { selectedRegion, setAuthorizedUsers, toggleDiskEncryptionEnabled, - tooltipInteractive, } = props; const { classes, cx } = useStyles(); @@ -156,7 +154,6 @@ export const AccessPanel = (props: Props) => { onChange={handleChange} placeholder={placeholder || 'Enter a password.'} required={required} - tooltipInteractive={tooltipInteractive} value={password || ''} /> diff --git a/packages/manager/src/components/BackupStatus/BackupStatus.tsx b/packages/manager/src/components/BackupStatus/BackupStatus.tsx index db476a48411..7b6123890b3 100644 --- a/packages/manager/src/components/BackupStatus/BackupStatus.tsx +++ b/packages/manager/src/components/BackupStatus/BackupStatus.tsx @@ -114,7 +114,6 @@ const BackupStatus = (props: Props) => { padding: 0, }} classes={{ tooltip: classes.tooltip }} - interactive status="help" text={backupsUnavailableMessage} /> diff --git a/packages/manager/src/components/Checkbox.tsx b/packages/manager/src/components/Checkbox.tsx index 5161f1536f9..fd2db5a9b8c 100644 --- a/packages/manager/src/components/Checkbox.tsx +++ b/packages/manager/src/components/Checkbox.tsx @@ -5,8 +5,8 @@ import * as React from 'react'; import CheckboxIcon from 'src/assets/icons/checkbox.svg'; import CheckboxCheckedIcon from 'src/assets/icons/checkboxChecked.svg'; -import { TooltipIcon } from 'src/components/TooltipIcon'; import { FormControlLabel } from 'src/components/FormControlLabel'; +import { TooltipIcon } from 'src/components/TooltipIcon'; interface Props extends CheckboxProps { /** @@ -17,11 +17,6 @@ interface Props extends CheckboxProps { * Renders a `FormControlLabel` that controls the underlying Checkbox with a label of `text` */ text?: JSX.Element | string; - /** - * Whether or not the tooltip is interactive - * @default false - */ - toolTipInteractive?: boolean; /** * Renders a tooltip to the right of the Checkbox */ @@ -44,7 +39,7 @@ interface Props extends CheckboxProps { * - If the user clicks the Back button, any changes made to checkboxes should be discarded and the original settings reinstated. */ export const Checkbox = (props: Props) => { - const { sxFormLabel, text, toolTipInteractive, toolTipText, ...rest } = props; + const { sxFormLabel, text, toolTipText, ...rest } = props; const BaseCheckbox = ( { return ( <> {CheckboxComponent} - {toolTipText ? ( - - ) : null} + {toolTipText ? : null} ); }; diff --git a/packages/manager/src/components/PasswordInput/PasswordInput.tsx b/packages/manager/src/components/PasswordInput/PasswordInput.tsx index 7f76d06bf24..a8e092169f2 100644 --- a/packages/manager/src/components/PasswordInput/PasswordInput.tsx +++ b/packages/manager/src/components/PasswordInput/PasswordInput.tsx @@ -19,7 +19,6 @@ const PasswordInput = (props: Props) => { hideStrengthLabel, hideValidation, required, - tooltipInteractive, value, ...rest } = props; @@ -33,7 +32,6 @@ const PasswordInput = (props: Props) => { {...rest} fullWidth required={required} - tooltipInteractive={tooltipInteractive} tooltipText={disabledReason} value={value} /> diff --git a/packages/manager/src/components/TextField.tsx b/packages/manager/src/components/TextField.tsx index bac96accd5d..15a71b066d7 100644 --- a/packages/manager/src/components/TextField.tsx +++ b/packages/manager/src/components/TextField.tsx @@ -157,7 +157,6 @@ interface LabelToolTipProps { interface InputToolTipProps { tooltipClasses?: string; - tooltipInteractive?: boolean; tooltipOnMouseEnter?: React.MouseEventHandler; tooltipPosition?: TooltipProps['placement']; tooltipText?: JSX.Element | string; @@ -251,7 +250,6 @@ export const TextField = (props: TextFieldProps) => { optional, required, tooltipClasses, - tooltipInteractive, tooltipOnMouseEnter, tooltipPosition, tooltipText, @@ -484,7 +482,6 @@ export const TextField = (props: TextFieldProps) => { padding: '6px', }} classes={{ popper: tooltipClasses }} - interactive={tooltipInteractive} onMouseEnter={tooltipOnMouseEnter} status="help" text={tooltipText} diff --git a/packages/manager/src/components/Toggle/Toggle.stories.tsx b/packages/manager/src/components/Toggle/Toggle.stories.tsx index 2a4d5c4d3f7..62c7ced65b1 100644 --- a/packages/manager/src/components/Toggle/Toggle.stories.tsx +++ b/packages/manager/src/components/Toggle/Toggle.stories.tsx @@ -11,12 +11,6 @@ export const Default: StoryObj = { render: (args) => , }; -export const WithInteractiveTooltip: StoryObj = { - render: (args) => ( - - ), -}; - const meta: Meta = { args: { disabled: false, diff --git a/packages/manager/src/components/Toggle/Toggle.test.tsx b/packages/manager/src/components/Toggle/Toggle.test.tsx index d32ba752ec2..c6f8d9e805e 100644 --- a/packages/manager/src/components/Toggle/Toggle.test.tsx +++ b/packages/manager/src/components/Toggle/Toggle.test.tsx @@ -13,7 +13,7 @@ describe('Toggle component', () => { }); it('should render a tooltip button', async () => { const screen = renderWithTheme( - + ); const tooltipButton = screen.getByRole('button'); expect(tooltipButton).toBeInTheDocument(); diff --git a/packages/manager/src/components/Toggle/Toggle.tsx b/packages/manager/src/components/Toggle/Toggle.tsx index 3c5cc0e5fa0..e1a478cbab6 100644 --- a/packages/manager/src/components/Toggle/Toggle.tsx +++ b/packages/manager/src/components/Toggle/Toggle.tsx @@ -6,10 +6,6 @@ import ToggleOn from 'src/assets/icons/toggleOn.svg'; import { TooltipIcon } from 'src/components/TooltipIcon'; export interface ToggleProps extends SwitchProps { - /** - * Makes a tooltip interactive (meaning the tooltip will not close when the user hovers over the tooltip). Note that in order for the tooltip to show up, tooltipText must be passed in as a prop. - */ - interactive?: boolean; /** * Content to display inside an optional tooltip. */ @@ -28,7 +24,7 @@ export interface ToggleProps extends SwitchProps { * > **Note:** Do not use toggles in long forms where other types of form fields are present, and users will need to click a Submit button for other changes to take effect. This scenario confuses users because they can’t be sure whether their toggle choice will take immediate effect. */ export const Toggle = (props: ToggleProps) => { - const { interactive, tooltipText, ...rest } = props; + const { tooltipText, ...rest } = props; return ( @@ -39,13 +35,7 @@ export const Toggle = (props: ToggleProps) => { icon={} {...rest} /> - {tooltipText && ( - - )} + {tooltipText && } ); }; diff --git a/packages/manager/src/components/TooltipIcon.tsx b/packages/manager/src/components/TooltipIcon.tsx index ef6f08c53b7..0977bf75fb9 100644 --- a/packages/manager/src/components/TooltipIcon.tsx +++ b/packages/manager/src/components/TooltipIcon.tsx @@ -25,7 +25,10 @@ interface EnhancedTooltipProps extends TooltipProps { } export interface TooltipIconProps - extends Omit { + extends Omit< + TooltipProps, + 'children' | 'disableInteractive' | 'leaveDelay' | 'title' + > { /** * An optional className that does absolutely nothing */ @@ -35,11 +38,6 @@ export interface TooltipIconProps * @todo this seems like a flaw... passing an icon should not require `status` to be `other` */ icon?: JSX.Element; - /** - * Makes the tooltip interactive (stays open when cursor is over tooltip) - * @default false - */ - interactive?: boolean; /** * Enables a leaveDelay of 3000ms * @default false @@ -92,7 +90,6 @@ export const TooltipIcon = (props: TooltipIconProps) => { const { classes, icon, - interactive, leaveDelay, status, sx, @@ -155,7 +152,6 @@ export const TooltipIcon = (props: TooltipIconProps) => { classes={classes} componentsProps={props.componentsProps} data-qa-help-tooltip - disableInteractive={!interactive} enterTouchDelay={0} leaveDelay={leaveDelay ? 3000 : undefined} leaveTouchDelay={5000} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx index ce0d238ee57..ab04662e0cd 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx @@ -269,7 +269,6 @@ export const MaintenanceWindow = (props: Props) => { your timezone settings. } - interactive status="help" />
diff --git a/packages/manager/src/features/Images/ImageUpload.tsx b/packages/manager/src/features/Images/ImageUpload.tsx index 9006c9a3be3..6e5738f00c0 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImageUpload.tsx @@ -261,7 +261,6 @@ export const ImageUpload: React.FC = (props) => { checked={isCloudInit} onChange={changeIsCloudInit} text="This image is cloud-init compatible" - toolTipInteractive toolTipText={cloudInitTooltipMessage} /> diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index 86e89d65d67..273e0abf6c3 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -238,7 +238,6 @@ export const CreateImageTab = () => {
} - interactive status="help" /> diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.tsx index b61b0f8aa94..4e8c63e0ac5 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.tsx @@ -135,7 +135,6 @@ export const UserDefinedFieldInput = ({ userDefinedField }: Props) => { onChange={(e) => field.onChange(e.target.value)} placeholder={isTokenPassword ? 'Enter your token' : 'Enter a password.'} required={isRequired} - tooltipInteractive={isTokenPassword} value={field.value ?? ''} /> ); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserDataHeading.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserDataHeading.tsx index 13dd5e70ab6..f21a50c4687 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserDataHeading.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/UserData/UserDataHeading.tsx @@ -41,7 +41,6 @@ export const UserDataHeading = () => { . } - interactive status="help" sxTooltipIcon={{ p: 0 }} /> diff --git a/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordionHeading.tsx b/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordionHeading.tsx index eda08d52851..24ae144710c 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordionHeading.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/UserDataAccordion/UserDataAccordionHeading.tsx @@ -38,7 +38,6 @@ export const UserDataAccordionHeading = (props: Props) => { } - interactive status="help" sxTooltipIcon={{ alignItems: 'baseline', padding: '0 8px' }} /> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx index a0ebfee8d16..b72943e99c6 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigDialog.tsx @@ -959,7 +959,6 @@ export const LinodeConfigDialog = (props: Props) => { paddingBottom: 0, paddingTop: 0, }} - interactive status="help" sx={{ tooltip: { maxWidth: 350 } }} text={networkInterfacesHelperText} @@ -1126,7 +1125,6 @@ export const LinodeConfigDialog = (props: Props) => { } checked={values.helpers.network} disabled={isReadOnly} - interactive={true} onChange={formik.handleChange} /> } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResizeUnifiedMigrationPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResizeUnifiedMigrationPanel.tsx index a5ccf0ccbf3..a1cbda31ec4 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResizeUnifiedMigrationPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResizeUnifiedMigrationPanel.tsx @@ -81,7 +81,6 @@ export const UnifiedMigrationPanel = (props: Props) => { )} } - interactive status="help" tooltipPosition="right" width={[theme.breakpoints.up('sm')] ? 375 : 300} @@ -111,7 +110,6 @@ export const UnifiedMigrationPanel = (props: Props) => { } - interactive status="help" tooltipPosition="right" width={[theme.breakpoints.up('sm')] ? 450 : 300} diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx index 4d7a9bfce06..c1de987b624 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeRow/LinodeRow.tsx @@ -132,7 +132,6 @@ export const LinodeRow = (props: Props) => {
Maintenance Scheduled } diff --git a/packages/manager/src/features/Linodes/PublicIpsUnassignedTooltip.tsx b/packages/manager/src/features/Linodes/PublicIpsUnassignedTooltip.tsx index fce59111dd2..eafaa1455f2 100644 --- a/packages/manager/src/features/Linodes/PublicIpsUnassignedTooltip.tsx +++ b/packages/manager/src/features/Linodes/PublicIpsUnassignedTooltip.tsx @@ -23,7 +23,6 @@ export const PublicIpsUnassignedTooltip = ( . } - interactive status="help" sxTooltipIcon={sxTooltipIcon} /> diff --git a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx index 17a523aa797..87ad2378350 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx @@ -103,7 +103,6 @@ export const DisplaySettings = () => { marginTop: '-2px', padding: 0, }} - interactive status="help" text={tooltipIconText} /> diff --git a/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx b/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx index 2b787736273..61d1a9325c9 100644 --- a/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx +++ b/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/FieldTypes/UserDefinedText.tsx @@ -14,7 +14,6 @@ interface Props { isPassword?: boolean; placeholder?: string; tooltip?: JSX.Element; - tooltipInteractive?: boolean; updateFormState: (key: string, value: any) => void; value: string; } @@ -41,14 +40,7 @@ class UserDefinedText extends React.Component { }; renderPasswordField = () => { - const { - error, - field, - isOptional, - placeholder, - tooltip, - tooltipInteractive, - } = this.props; + const { error, field, isOptional, placeholder, tooltip } = this.props; return ( { password={this.props.value} placeholder={placeholder} required={!isOptional} - tooltipInteractive={tooltipInteractive} /> ); }; diff --git a/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx b/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx index ab85913ec11..691a0549165 100644 --- a/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx +++ b/packages/manager/src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel.tsx @@ -102,7 +102,6 @@ const renderField = ( isOptional={isOptional} isPassword={true} placeholder={isTokenPassword ? 'Enter your token' : field.example} - tooltipInteractive={isTokenPassword} updateFor={[field.label, udf_data[field.name], error]} updateFormState={handleChange} /** diff --git a/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx b/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx index 946302aa5db..795aa393d5f 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/AssignIPRanges.tsx @@ -54,7 +54,6 @@ export const AssignIPRanges = (props: Props) => { marginLeft: theme.spacing(0.5), padding: theme.spacing(0.5), }} - interactive status="help" text={IPv4RangesDescriptionJSX} /> diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx index 9539d229c32..99d256362d6 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetLinodeRow.tsx @@ -148,7 +148,6 @@ export const SubnetLinodeRow = (props: Props) => { } icon={} - interactive status="other" sxTooltipIcon={{ paddingLeft: 0 }} /> From 1c5e92617d7f77a78454284a7dcb9ec160f05a1d Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Thu, 23 May 2024 10:03:22 -0400 Subject: [PATCH 015/163] =?UTF-8?q?upcoming:=20[M3-8032]=20=E2=80=93=20Add?= =?UTF-8?q?=20Encrypted/Not=20Encrypted=20status=20to=20Node=20Pool=20tabl?= =?UTF-8?q?e=20(#10480)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/api-v4/src/images/images.ts | 4 +- packages/api-v4/src/kubernetes/types.ts | 2 +- ...r-10480-upcoming-features-1716321944627.md | 5 ++ .../src/assets/icons/divider-vertical.svg | 3 + packages/manager/src/assets/icons/lock.svg | 3 + packages/manager/src/assets/icons/unlock.svg | 4 ++ .../components/DiskEncryption/constants.tsx | 3 + .../NodePoolsDisplay/NodePool.tsx | 17 +++-- .../NodePoolsDisplay/NodePoolsDisplay.tsx | 11 +-- .../NodePoolsDisplay/NodeTable.styles.ts | 15 +++- .../NodePoolsDisplay/NodeTable.test.tsx | 50 ++++++++++++- .../NodePoolsDisplay/NodeTable.tsx | 71 +++++++++++++++++-- packages/manager/src/mocks/serverHandlers.ts | 9 ++- 13 files changed, 174 insertions(+), 23 deletions(-) create mode 100644 packages/manager/.changeset/pr-10480-upcoming-features-1716321944627.md create mode 100644 packages/manager/src/assets/icons/divider-vertical.svg create mode 100644 packages/manager/src/assets/icons/lock.svg create mode 100644 packages/manager/src/assets/icons/unlock.svg diff --git a/packages/api-v4/src/images/images.ts b/packages/api-v4/src/images/images.ts index 9c9984bdbd1..720d75bcdda 100644 --- a/packages/api-v4/src/images/images.ts +++ b/packages/api-v4/src/images/images.ts @@ -11,8 +11,8 @@ import Request, { setURL, setXFilter, } from '../request'; -import { Filter, Params, ResourcePage as Page } from '../types'; -import { +import type { Filter, Params, ResourcePage as Page } from '../types'; +import type { CreateImagePayload, Image, ImageUploadPayload, diff --git a/packages/api-v4/src/kubernetes/types.ts b/packages/api-v4/src/kubernetes/types.ts index c8d25118e35..8e2d176572c 100644 --- a/packages/api-v4/src/kubernetes/types.ts +++ b/packages/api-v4/src/kubernetes/types.ts @@ -1,4 +1,4 @@ -import type { EncryptionStatus } from 'src/linodes'; +import type { EncryptionStatus } from '../linodes'; export interface KubernetesCluster { created: string; diff --git a/packages/manager/.changeset/pr-10480-upcoming-features-1716321944627.md b/packages/manager/.changeset/pr-10480-upcoming-features-1716321944627.md new file mode 100644 index 00000000000..ccf6bb170dd --- /dev/null +++ b/packages/manager/.changeset/pr-10480-upcoming-features-1716321944627.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Encrypted/Not Encrypted status to LKE Node Pool table ([#10480](https://github.com/linode/manager/pull/10480)) diff --git a/packages/manager/src/assets/icons/divider-vertical.svg b/packages/manager/src/assets/icons/divider-vertical.svg new file mode 100644 index 00000000000..79add159022 --- /dev/null +++ b/packages/manager/src/assets/icons/divider-vertical.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/manager/src/assets/icons/lock.svg b/packages/manager/src/assets/icons/lock.svg new file mode 100644 index 00000000000..ca135909b4f --- /dev/null +++ b/packages/manager/src/assets/icons/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/manager/src/assets/icons/unlock.svg b/packages/manager/src/assets/icons/unlock.svg new file mode 100644 index 00000000000..ce413046282 --- /dev/null +++ b/packages/manager/src/assets/icons/unlock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/manager/src/components/DiskEncryption/constants.tsx b/packages/manager/src/components/DiskEncryption/constants.tsx index dbdce42a9c7..5d0ffe10ec8 100644 --- a/packages/manager/src/components/DiskEncryption/constants.tsx +++ b/packages/manager/src/components/DiskEncryption/constants.tsx @@ -19,3 +19,6 @@ export const DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY = export const DISK_ENCRYPTION_BACKUPS_CAVEAT_COPY = 'Virtual Machine Backups are not encrypted.'; + +export const DISK_ENCRYPTION_NODE_POOL_GUIDANCE_COPY = + 'To enable disk encryption, delete the node pool and create a new node pool. New node pools are always encrypted.'; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx index ed4ff6d7878..02e61f7aec2 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx @@ -1,7 +1,3 @@ -import { - AutoscaleSettings, - PoolNodeResponse, -} from '@linode/api-v4/lib/kubernetes'; import { Theme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; @@ -13,8 +9,15 @@ import { Typography } from 'src/components/Typography'; import { NodeTable } from './NodeTable'; +import type { + AutoscaleSettings, + PoolNodeResponse, +} from '@linode/api-v4/lib/kubernetes'; +import type { EncryptionStatus } from '@linode/api-v4/lib/linodes/types'; + interface Props { autoscaler: AutoscaleSettings; + encryptionStatus: EncryptionStatus | undefined; handleClickResize: (poolId: number) => void; isOnlyNodePool: boolean; nodes: PoolNodeResponse[]; @@ -40,9 +43,10 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); -const NodePool: React.FC = (props) => { +export const NodePool = (props: Props) => { const { autoscaler, + encryptionStatus, handleClickResize, isOnlyNodePool, nodes, @@ -126,6 +130,7 @@ const NodePool: React.FC = (props) => { xs={12} > = (props) => { ); }; - -export default NodePool; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx index e4f5d34f542..97cb7c652e8 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePoolsDisplay.tsx @@ -1,14 +1,14 @@ -import Grid from '@mui/material/Unstable_Grid2'; import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; +import Grid from '@mui/material/Unstable_Grid2'; import React, { useState } from 'react'; import { Waypoint } from 'react-waypoint'; +import { makeStyles } from 'tss-react/mui'; import { Button } from 'src/components/Button/Button'; import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; -import { Typography } from 'src/components/Typography'; import { Paper } from 'src/components/Paper'; +import { Typography } from 'src/components/Typography'; import { useAllKubernetesNodePoolQuery } from 'src/queries/kubernetes'; import { useSpecificTypes } from 'src/queries/types'; import { extendTypesQueryResult } from 'src/utilities/extendType'; @@ -18,7 +18,7 @@ import { RecycleNodePoolDialog } from '../RecycleNodePoolDialog'; import { AddNodePoolDrawer } from './AddNodePoolDrawer'; import { AutoscalePoolDialog } from './AutoscalePoolDialog'; import { DeleteNodePoolDialog } from './DeleteNodePoolDialog'; -import NodePool from './NodePool'; +import { NodePool } from './NodePool'; import { RecycleNodeDialog } from './RecycleNodeDialog'; import { ResizeNodePoolDrawer } from './ResizeNodePoolDrawer'; @@ -152,7 +152,7 @@ export const NodePoolsDisplay = (props: Props) => { {_pools?.map((thisPool) => { - const { id, nodes } = thisPool; + const { disk_encryption, id, nodes } = thisPool; const thisPoolType = types?.find( (thisType) => thisType.id === thisPool.type @@ -181,6 +181,7 @@ export const NodePoolsDisplay = (props: Props) => { setIsRecycleNodeOpen(true); }} autoscaler={thisPool.autoscaler} + encryptionStatus={disk_encryption} handleClickResize={handleOpenResizeDrawer} isOnlyNodePool={pools?.length === 1} nodes={nodes ?? []} diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts index 482ab5b66fc..f272e64c72c 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.styles.ts @@ -1,8 +1,10 @@ import { styled } from '@mui/material/styles'; +import VerticalDivider from 'src/assets/icons/divider-vertical.svg'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { Table } from 'src/components/Table'; import { TableRow } from 'src/components/TableRow'; +import { Typography } from 'src/components/Typography'; export const StyledTableRow = styled(TableRow, { label: 'TableRow', @@ -19,7 +21,6 @@ export const StyledTableRow = styled(TableRow, { opacity: 1, }, marginLeft: 4, - top: 1, })); export const StyledTable = styled(Table, { @@ -40,3 +41,15 @@ export const StyledCopyTooltip = styled(CopyTooltip, { marginLeft: 4, top: 1, })); + +export const StyledVerticalDivider = styled(VerticalDivider, { + label: 'StyledVerticalDivider', +})(({ theme }) => ({ + margin: `0 ${theme.spacing(2)}`, +})); + +export const StyledTypography = styled(Typography, { + label: 'StyledTypography', +})(({ theme }) => ({ + margin: `0 0 0 ${theme.spacing()}`, +})); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx index 4298d833767..49216784a3f 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.test.tsx @@ -4,13 +4,14 @@ import { kubeLinodeFactory } from 'src/factories/kubernetesCluster'; import { linodeFactory } from 'src/factories/linodes'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { NodeTable, Props } from './NodeTable'; +import { NodeTable, Props, encryptionStatusTestId } from './NodeTable'; const mockLinodes = linodeFactory.buildList(3); const mockKubeNodes = kubeLinodeFactory.buildList(3); const props: Props = { + encryptionStatus: 'enabled', nodes: mockKubeNodes, openRecycleNodeDialog: vi.fn(), poolId: 1, @@ -20,6 +21,29 @@ const props: Props = { beforeAll(() => linodeFactory.resetSequenceNumber()); describe('NodeTable', () => { + const mocks = vi.hoisted(() => { + return { + useIsDiskEncryptionFeatureEnabled: vi.fn(), + }; + }); + + vi.mock('src/components/DiskEncryption/utils.ts', async () => { + const actual = await vi.importActual( + 'src/components/DiskEncryption/utils.ts' + ); + return { + ...actual, + __esModule: true, + useIsDiskEncryptionFeatureEnabled: mocks.useIsDiskEncryptionFeatureEnabled.mockImplementation( + () => { + return { + isDiskEncryptionFeatureEnabled: false, // indicates the feature flag is off or account capability is absent + }; + } + ), + }; + }); + it('includes label, status, and IP columns', () => { const { findByText } = renderWithTheme(); mockLinodes.forEach(async (thisLinode) => { @@ -28,8 +52,32 @@ describe('NodeTable', () => { await findByText('Ready'); }); }); + it('includes the Pool ID', () => { const { getByText } = renderWithTheme(); getByText('Pool ID 1'); }); + + it('does not display the encryption status of the pool if the account lacks the capability or the feature flag is off', () => { + // situation where isDiskEncryptionFeatureEnabled === false + const { queryByTestId } = renderWithTheme(); + const encryptionStatusFragment = queryByTestId(encryptionStatusTestId); + + expect(encryptionStatusFragment).not.toBeInTheDocument(); + }); + + it('displays the encryption status of the pool if the feature flag is on and the account has the capability', () => { + mocks.useIsDiskEncryptionFeatureEnabled.mockImplementationOnce(() => { + return { + isDiskEncryptionFeatureEnabled: true, + }; + }); + + const { queryByTestId } = renderWithTheme(); + const encryptionStatusFragment = queryByTestId(encryptionStatusTestId); + + expect(encryptionStatusFragment).toBeInTheDocument(); + + mocks.useIsDiskEncryptionFeatureEnabled.mockRestore(); + }); }); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx index abb227b83c6..be95c8ab1df 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx @@ -1,6 +1,10 @@ -import { PoolNodeResponse } from '@linode/api-v4/lib/kubernetes'; import * as React from 'react'; +import Lock from 'src/assets/icons/lock.svg'; +import Unlock from 'src/assets/icons/unlock.svg'; +import { Box } from 'src/components/Box'; +import { DISK_ENCRYPTION_NODE_POOL_GUIDANCE_COPY } from 'src/components/DiskEncryption/constants'; +import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; import OrderBy from 'src/components/OrderBy'; import Paginate from 'src/components/Paginate'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -11,26 +15,45 @@ import { TableFooter } from 'src/components/TableFooter'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; +import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { LinodeWithMaintenance } from 'src/utilities/linodes'; import { NodeRow as _NodeRow } from './NodeRow'; -import { StyledTable } from './NodeTable.styles'; +import { + StyledTable, + StyledTypography, + StyledVerticalDivider, +} from './NodeTable.styles'; import type { NodeRow } from './NodeRow'; +import type { PoolNodeResponse } from '@linode/api-v4/lib/kubernetes'; +import type { EncryptionStatus } from '@linode/api-v4/lib/linodes/types'; export interface Props { + encryptionStatus: EncryptionStatus | undefined; nodes: PoolNodeResponse[]; openRecycleNodeDialog: (nodeID: string, linodeLabel: string) => void; poolId: number; typeLabel: string; } +export const encryptionStatusTestId = 'encryption-status-fragment'; + export const NodeTable = React.memo((props: Props) => { - const { nodes, openRecycleNodeDialog, poolId, typeLabel } = props; + const { + encryptionStatus, + nodes, + openRecycleNodeDialog, + poolId, + typeLabel, + } = props; const { data: linodes, error, isLoading } = useAllLinodesQuery(); + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); const rowData = nodes.map((thisNode) => nodeToRow(thisNode, linodes ?? [])); @@ -116,7 +139,26 @@ export const NodeTable = React.memo((props: Props) => { - Pool ID {poolId} + {isDiskEncryptionFeatureEnabled && + encryptionStatus !== undefined ? ( + + Pool ID {poolId} + + + + ) : ( + Pool ID {poolId} + )} @@ -157,3 +199,24 @@ export const nodeToRow = ( nodeStatus: node.status, }; }; + +export const EncryptedStatus = ({ + encryptionStatus, + tooltipText, +}: { + encryptionStatus: EncryptionStatus; + tooltipText: string | undefined; +}) => { + return encryptionStatus === 'enabled' ? ( + <> + + Encrypted + + ) : encryptionStatus === 'disabled' ? ( + <> + + Not Encrypted + {tooltipText ? : null} + + ) : null; +}; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index c0dceb9e76e..e1c89236f27 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -849,9 +849,14 @@ export const handlers = [ return HttpResponse.json(cluster); }), http.get('*/lke/clusters/:clusterId/pools', async () => { - const pools = nodePoolFactory.buildList(10); + const encryptedPools = nodePoolFactory.buildList(5); + const unencryptedPools = nodePoolFactory.buildList(5, { + disk_encryption: 'disabled', + }); nodePoolFactory.resetSequenceNumber(); - return HttpResponse.json(makeResourcePage(pools)); + return HttpResponse.json( + makeResourcePage([...encryptedPools, ...unencryptedPools]) + ); }), http.get('*/lke/clusters/*/api-endpoints', async () => { const endpoints = kubeEndpointFactory.buildList(2); From 98f76158e54fd04fc0c56b2fce4cbd7f415bb79b Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Thu, 23 May 2024 10:38:17 -0400 Subject: [PATCH 016/163] test: [M3-8060, M3-8061] - Cypress tests for Linode Create v2 flows (#10469) * Rename Linode Create tests to `legacy-create-linode.spec.ts` * Add data-qa-paper attribute to papers * Add Linode create timeout constant * Add page utilities to ease interaction with Linode create and VPC create forms * Add v2 Linode create end-to-end tests for each plan type * Add v2 Linode create integration tests for VPC assignment * Add Linode Create w/ VLAN tests * Add Linode Create w/ SSH Key and user data specs * Add Linode Create w/ VLAN integration tests * Add Linode Create mobile screen size tests * Add Linode Create user data UI tests * Refactor `mockGetImage` to be more flexible * Added changeset: Add Cypress test coverage for Linode Create v2 flow --- .../pr-10469-tests-1715717780880.md | 5 + .../pr-10469-tests-1715717804841.md | 5 + .../pr-10469-tests-1715717849362.md | 5 + .../pr-10469-tests-1715717865422.md | 5 + .../pr-10469-tests-1715717889337.md | 5 + .../pr-10469-tests-1716471500474.md | 5 + .../core/linodes/create-linode-mobile.spec.ts | 77 ++ .../create-linode-with-ssh-key.spec.ts | 186 +++++ .../create-linode-with-user-data.spec.ts | 149 ++++ .../linodes/create-linode-with-vlan.spec.ts | 240 +++++++ .../linodes/create-linode-with-vpc.spec.ts | 267 +++++++ .../e2e/core/linodes/create-linode.spec.ts | 671 +++--------------- .../core/linodes/legacy-create-linode.spec.ts | 603 ++++++++++++++++ .../user-data/user-data-config-basic.yml | 11 + .../cypress/support/constants/environment.ts | 21 + .../cypress/support/constants/linodes.ts | 10 + .../cypress/support/intercepts/images.ts | 25 +- .../cypress/support/intercepts/profile.ts | 46 ++ .../manager/cypress/support/ui/accordion.ts | 29 +- .../manager/cypress/support/ui/pages/index.ts | 15 + .../support/ui/pages/linode-create-page.ts | 94 +++ .../support/ui/pages/vpc-create-drawer.ts | 80 +++ packages/manager/src/components/Paper.tsx | 1 + .../Linodes/LinodeCreatev2/Summary.tsx | 5 +- 24 files changed, 1975 insertions(+), 585 deletions(-) create mode 100644 packages/manager/.changeset/pr-10469-tests-1715717780880.md create mode 100644 packages/manager/.changeset/pr-10469-tests-1715717804841.md create mode 100644 packages/manager/.changeset/pr-10469-tests-1715717849362.md create mode 100644 packages/manager/.changeset/pr-10469-tests-1715717865422.md create mode 100644 packages/manager/.changeset/pr-10469-tests-1715717889337.md create mode 100644 packages/manager/.changeset/pr-10469-tests-1716471500474.md create mode 100644 packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts create mode 100644 packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts create mode 100644 packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts create mode 100644 packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts create mode 100644 packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts create mode 100644 packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts create mode 100644 packages/manager/cypress/fixtures/user-data/user-data-config-basic.yml create mode 100644 packages/manager/cypress/support/constants/environment.ts create mode 100644 packages/manager/cypress/support/constants/linodes.ts create mode 100644 packages/manager/cypress/support/ui/pages/index.ts create mode 100644 packages/manager/cypress/support/ui/pages/linode-create-page.ts create mode 100644 packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts diff --git a/packages/manager/.changeset/pr-10469-tests-1715717780880.md b/packages/manager/.changeset/pr-10469-tests-1715717780880.md new file mode 100644 index 00000000000..9ab45c98475 --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1715717780880.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Linode Create v2 end-to-end tests ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/.changeset/pr-10469-tests-1715717804841.md b/packages/manager/.changeset/pr-10469-tests-1715717804841.md new file mode 100644 index 00000000000..8baca1f3156 --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1715717804841.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Linode Create v2 integration tests for VLAN flows ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/.changeset/pr-10469-tests-1715717849362.md b/packages/manager/.changeset/pr-10469-tests-1715717849362.md new file mode 100644 index 00000000000..3d0b7fb4c80 --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1715717849362.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Linode Create v2 integration tests for VPC flows ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/.changeset/pr-10469-tests-1715717865422.md b/packages/manager/.changeset/pr-10469-tests-1715717865422.md new file mode 100644 index 00000000000..1ee3be6625a --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1715717865422.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Linode Create v2 integration tests for SSH key flows ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/.changeset/pr-10469-tests-1715717889337.md b/packages/manager/.changeset/pr-10469-tests-1715717889337.md new file mode 100644 index 00000000000..6f5ee7bf11e --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1715717889337.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Linode Create v2 integration tests for cloud-init flows ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/.changeset/pr-10469-tests-1716471500474.md b/packages/manager/.changeset/pr-10469-tests-1716471500474.md new file mode 100644 index 00000000000..090c468a04d --- /dev/null +++ b/packages/manager/.changeset/pr-10469-tests-1716471500474.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress test coverage for Linode Create v2 flow ([#10469](https://github.com/linode/manager/pull/10469)) diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts new file mode 100644 index 00000000000..bf7774cc0bd --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts @@ -0,0 +1,77 @@ +/** + * @file Smoke tests for Linode Create flow across common mobile viewport sizes. + */ + +import { linodeFactory } from 'src/factories'; +import { MOBILE_VIEWPORTS } from 'support/constants/environment'; +import { linodeCreatePage } from 'support/ui/pages'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { ui } from 'support/ui'; +import { mockCreateLinode } from 'support/intercepts/linodes'; + +describe('Linode create mobile smoke', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + MOBILE_VIEWPORTS.forEach((viewport) => { + /* + * - Confirms Linode create flow can be completed on common mobile screen sizes + * - Creates a basic Nanode and confirms interactions succeed and outgoing request contains expected data. + */ + it(`can create Linode (${viewport.label})`, () => { + const mockLinodeRegion = chooseRegion(); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + }); + + mockCreateLinode(mockLinode).as('createLinode'); + + cy.viewport(viewport.width, viewport.height); + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(mockLinodeRegion.id); + linodeCreatePage.selectPlanCard('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.setRootPassword(randomString(32)); + + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('Nanode 1 GB').should('be.visible'); + cy.findByText('Debian 11').should('be.visible'); + cy.findByText(mockLinodeRegion.label).should('be.visible'); + }); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestBody = xhr.request.body; + + expect(requestBody['image']).to.equal('linode/debian11'); + expect(requestBody['label']).to.equal(mockLinode.label); + expect(requestBody['region']).to.equal(mockLinodeRegion.id); + expect(requestBody['type']).to.equal('g6-nanode-1'); + }); + + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts new file mode 100644 index 00000000000..07a04310671 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts @@ -0,0 +1,186 @@ +import { + accountUserFactory, + linodeFactory, + sshKeyFactory, +} from 'src/factories'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; +import { mockGetUser, mockGetUsers } from 'support/intercepts/account'; +import { mockCreateLinode } from 'support/intercepts/linodes'; +import { linodeCreatePage } from 'support/ui/pages'; +import { ui } from 'support/ui'; +import { mockCreateSSHKey } from 'support/intercepts/profile'; + +describe('Create Linode with SSH Key', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms UI flow when creating a Linode with an authorized SSH key. + * - Confirms that existing SSH keys are listed on page and can be selected. + * - Confirms that outgoing Linode create API request contains authorized user for chosen key. + */ + it('can add an existing SSH key during Linode create flow', () => { + const linodeRegion = chooseRegion(); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + const mockSshKey = sshKeyFactory.build({ + label: randomLabel(), + }); + + const mockUser = accountUserFactory.build({ + username: randomLabel(), + ssh_keys: [mockSshKey.label], + }); + + mockGetUsers([mockUser]); + mockGetUser(mockUser); + mockCreateLinode(mockLinode).as('createLinode'); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm that SSH key is listed, then select it. + cy.findByText(mockSshKey.label) + .scrollIntoView() + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText(mockUser.username); + cy.findByLabelText(`Enable SSH for ${mockUser.username}`).click(); + }); + + // Click "Create Linode" button and confirm outgoing request data. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that outgoing Linode create request contains authorized user that + // corresponds to the selected SSH key. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload['authorized_users'][0]).to.equal(mockUser.username); + }); + }); + + /* + * - Confirms UI flow when creating and selecting an SSH key during Linode create flow. + * - Confirms that new SSH key is automatically shown in Linode create page. + * - Confirms that outgoing Linode create API request contains authorized user for new key. + */ + it('can add a new SSH key during Linode create flow', () => { + const linodeRegion = chooseRegion(); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + const mockSshKey = sshKeyFactory.build({ + label: randomLabel(), + ssh_key: `ssh-rsa ${randomString(16)}`, + }); + + const mockUser = accountUserFactory.build({ + username: randomLabel(), + ssh_keys: [], + }); + + const mockUserWithKey = { + ...mockUser, + ssh_keys: [mockSshKey.label], + }; + + mockGetUser(mockUser); + mockGetUsers([mockUser]); + mockCreateLinode(mockLinode).as('createLinode'); + mockCreateSSHKey(mockSshKey).as('createSSHKey'); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm that no SSH keys are listed for the mocked user. + cy.findByText(mockUser.username) + .scrollIntoView() + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('None').should('be.visible'); + cy.findByLabelText(`Enable SSH for ${mockUser.username}`).should( + 'be.disabled' + ); + }); + + // Click "Add an SSH Key" and enter a label and the public key, then submit. + ui.button + .findByTitle('Add an SSH Key') + .should('be.visible') + .should('be.enabled') + .click(); + + mockGetUsers([mockUserWithKey]).as('refetchUsers'); + ui.drawer + .findByTitle('Add SSH Key') + .should('be.visible') + .within(() => { + cy.findByLabelText('Label').type(mockSshKey.label); + cy.findByLabelText('SSH Public Key').type(mockSshKey.ssh_key); + ui.button + .findByTitle('Add Key') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@createSSHKey', '@refetchUsers']); + + // Confirm that the new SSH key is listed, and select it to be added to the Linode. + cy.findByText(mockSshKey.label) + .scrollIntoView() + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByLabelText(`Enable SSH for ${mockUser.username}`).click(); + }); + + // Click "Create Linode" button and confirm outgoing request data. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm that outgoing Linode create request contains authorized user that + // corresponds to the new SSH key. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload['authorized_users'][0]).to.equal(mockUser.username); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts new file mode 100644 index 00000000000..21096becdf3 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts @@ -0,0 +1,149 @@ +import { imageFactory, linodeFactory, regionFactory } from 'src/factories'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { mockGetAllImages, mockGetImage } from 'support/intercepts/images'; +import { + mockCreateLinode, + mockGetLinodeDetails, +} from 'support/intercepts/linodes'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +describe('Create Linode with user data', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms UI flow to create a Linode with cloud-init user data specified. + * - Confirms that outgoing API request contains expected user data payload. + */ + it('can specify user data during Linode Create flow', () => { + const linodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Metadata'], + }); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + const userDataFixturePath = 'user-data/user-data-config-basic.yml'; + + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin('/linodes/create'); + + // Fill out create form, selecting a region and image that both have + // cloud-init capabilities. + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Expand "Add User Data" accordion and enter user data config. + ui.accordionHeading + .findByTitle('Add User Data') + .should('be.visible') + .click(); + + cy.fixture(userDataFixturePath).then((userDataContents) => { + ui.accordion.findByTitle('Add User Data').within(() => { + cy.findByText('User Data').click(); + cy.focused().type(userDataContents); + }); + + // Submit form to create Linode and confirm that outgoing API request + // contains expected user data. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + expect(requestPayload['metadata']['user_data']).to.equal( + btoa(userDataContents) + ); + }); + }); + }); + + /* + * - Confirms UI flow when creating a Linode using a region that lacks cloud-init capability. + * - Confirms that "Add User Data" section is hidden when selected region lacks cloud-init. + */ + it('cannot specify user data when selected region does not support it', () => { + const mockLinodeRegion = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + }); + + mockGetRegions([mockLinodeRegion]); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(mockLinodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + + // Confirm that "Add User Data" section is hidden when selected region + // lacks cloud-init capability. + cy.findByText('Add User Data').should('not.exist'); + }); + + /* + * - Confirms UI flow when creating a Linode using an image that lacks cloud-init capability. + * - Confirms that "Add User Data" section is hidden when selected image lacks cloud-init. + */ + it('cannot specify user data when selected image does not support it', () => { + const linodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Metadata'], + }); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + const mockImage = imageFactory.build({ + id: `linode/${randomLabel()}`, + label: randomLabel(), + created_by: 'linode', + is_public: true, + vendor: 'Debian', + // `cloud-init` is omitted from Image capabilities. + capabilities: [], + }); + + mockGetImage(mockImage.id, mockImage); + mockGetAllImages([mockImage]); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage(mockImage.label); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + + // Confirm that "Add User Data" section is hidden when selected image + // lacks cloud-init capability. + cy.findByText('Add User Data').should('not.exist'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts new file mode 100644 index 00000000000..7b3d495de94 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts @@ -0,0 +1,240 @@ +import { linodeFactory, regionFactory, VLANFactory } from 'src/factories'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { chooseRegion } from 'support/util/regions'; +import { + randomIp, + randomLabel, + randomNumber, + randomString, +} from 'support/util/random'; +import { mockGetVLANs } from 'support/intercepts/vlans'; +import { mockCreateLinode } from 'support/intercepts/linodes'; + +describe('Create Linode with VLANs', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Uses mock API data to confirm VLAN attachment UI flow during Linode create. + * - Confirms that outgoing Linode create API request contains expected data for VLAN. + * - Confirms that attached VLAN is reflected in the Linode create summary. + */ + it('can assign existing VLANs during Linode create flow', () => { + const mockLinodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Vlans'], + }); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + }); + + const mockVlan = VLANFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + cidr_block: `${randomIp()}/24`, + linodes: [], + }); + + mockGetVLANs([mockVlan]); + mockCreateLinode(mockLinode).as('createLinode'); + cy.visitWithLogin('/linodes/create'); + + // Fill out necessary Linode create fields. + linodeCreatePage.selectRegionById(mockLinodeRegion.id); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Open VLAN accordion and select existing VLAN. + ui.accordionHeading.findByTitle('VLAN').click(); + ui.accordion + .findByTitle('VLAN') + .scrollIntoView() + .should('be.visible') + .within(() => { + cy.findByLabelText('VLAN').should('be.enabled').type(mockVlan.label); + + ui.autocompletePopper + .findByTitle(mockVlan.label) + .should('be.visible') + .click(); + + cy.findByLabelText(/IPAM Address/) + .should('be.enabled') + .type(mockVlan.cidr_block); + }); + + // Confirm that VLAN attachment is listed in summary, then create Linode. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('VLAN Attached').should('be.visible'); + }); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm outgoing API request payload has expected data. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const expectedPublicInterface = requestPayload['interfaces'][0]; + const expectedVlanInterface = requestPayload['interfaces'][1]; + + // Confirm that first interface is for public internet. + expect(expectedPublicInterface['purpose']).to.equal('public'); + + // Confirm that second interface is our chosen VLAN. + expect(expectedVlanInterface['purpose']).to.equal('vlan'); + expect(expectedVlanInterface['label']).to.equal(mockVlan.label); + expect(expectedVlanInterface['ipam_address']).to.equal( + mockVlan.cidr_block + ); + }); + + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // TODO Confirm whether toast notification should appear on Linode create. + }); + + /* + * - Uses mock API data to confirm VLAN creation and attachment UI flow during Linode create. + * - Confirms that outgoing Linode create API request contains expected data for new VLAN. + * - Confirms that attached VLAN is reflected in the Linode create summary. + */ + it('can assign new VLANs during Linode create flow', () => { + const mockLinodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Vlans'], + }); + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + }); + + const mockVlan = VLANFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: mockLinodeRegion.id, + cidr_block: `${randomIp()}/24`, + linodes: [], + }); + + mockGetVLANs([]); + mockCreateLinode(mockLinode).as('createLinode'); + cy.visitWithLogin('/linodes/create'); + + // Fill out necessary Linode create fields. + linodeCreatePage.selectRegionById(mockLinodeRegion.id); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Open VLAN accordion and specify new VLAN. + ui.accordionHeading.findByTitle('VLAN').click(); + ui.accordion + .findByTitle('VLAN') + .scrollIntoView() + .should('be.visible') + .within(() => { + cy.findByLabelText('VLAN').should('be.enabled').type(mockVlan.label); + + ui.autocompletePopper + .findByTitle(`Create "${mockVlan.label}"`) + .should('be.visible') + .click(); + }); + + // Confirm that VLAN attachment is listed in summary, then create Linode. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('VLAN Attached').should('be.visible'); + }); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm outgoing API request payload has expected data. + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const expectedPublicInterface = requestPayload['interfaces'][0]; + const expectedVlanInterface = requestPayload['interfaces'][1]; + + // Confirm that first interface is for public internet. + expect(expectedPublicInterface['purpose']).to.equal('public'); + + // Confirm that second interface is our chosen VLAN. + expect(expectedVlanInterface['purpose']).to.equal('vlan'); + expect(expectedVlanInterface['label']).to.equal(mockVlan.label); + expect(expectedVlanInterface['ipam_address']).to.equal(''); + }); + + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // TODO Confirm whether toast notification should appear on Linode create. + }); + + /* + * - Uses mock API data to confirm that VLANs cannot be assigned to Linodes in regions without capability. + * - Confirms that VLAN fields are disabled before and after selecting a region. + */ + it('cannot assign VLANs in regions without capability', () => { + const availabilityNotice = + 'VLANs are currently available in select regions.'; + + const nonVlanRegion = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const vlanRegion = regionFactory.build({ + capabilities: ['Linodes', 'Vlans'], + }); + + mockGetRegions([nonVlanRegion, vlanRegion]); + cy.visitWithLogin('/linodes/create'); + + // Expand VLAN accordion, confirm VLAN availability notice is displayed and + // that VLAN fields are disabled while no region is selected. + ui.accordionHeading.findByTitle('VLAN').click(); + ui.accordion + .findByTitle('VLAN') + .scrollIntoView() + .within(() => { + cy.contains(availabilityNotice).should('be.visible'); + cy.findByLabelText('VLAN').should('be.disabled'); + cy.findByLabelText(/IPAM Address/).should('be.disabled'); + }); + + // Select a region that is known not to have VLAN capability. + linodeCreatePage.selectRegionById(nonVlanRegion.id); + + // Confirm that VLAN fields are still disabled. + ui.accordion + .findByTitle('VLAN') + .scrollIntoView() + .within(() => { + cy.findByLabelText('VLAN').should('be.disabled'); + cy.findByLabelText(/IPAM Address/).should('be.disabled'); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts new file mode 100644 index 00000000000..668c344f2de --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts @@ -0,0 +1,267 @@ +import { + linodeFactory, + regionFactory, + subnetFactory, + vpcFactory, +} from 'src/factories'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { + mockCreateLinode, + mockGetLinodeDetails, +} from 'support/intercepts/linodes'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + mockCreateVPC, + mockCreateVPCError, + mockGetVPC, + mockGetVPCs, +} from 'support/intercepts/vpc'; +import { ui } from 'support/ui'; +import { linodeCreatePage, vpcCreateDrawer } from 'support/ui/pages'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { + randomIp, + randomLabel, + randomNumber, + randomPhrase, + randomString, +} from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +describe('Create Linode with VPCs', () => { + // TODO Remove feature flag mocks when `linodeCreateRefactor` flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * - Confirms UI flow to create a Linode with an existing VPC assigned using mock API data. + * - Confirms that VPC assignment is reflected in create summary section. + * - Confirms that outgoing API request contains expected VPC interface data. + */ + it('can assign existing VPCs during Linode Create flow', () => { + const linodeRegion = chooseRegion({ capabilities: ['VPCs'] }); + + const mockSubnet = subnetFactory.build({ + id: randomNumber(), + label: randomLabel(), + linodes: [], + ipv4: `${randomIp()}/0`, + }); + + const mockVPC = vpcFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + subnets: [mockSubnet], + }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + // + }); + + mockGetVPCs([mockVPC]).as('getVPCs'); + mockGetVPC(mockVPC).as('getVPC'); + mockCreateLinode(mockLinode).as('createLinode'); + mockGetLinodeDetails(mockLinode.id, mockLinode); + + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm that mocked VPC is shown in the Autocomplete, and then select it. + cy.findByText('Assign VPC').click().type(`${mockVPC.label}`); + + ui.autocompletePopper + .findByTitle(mockVPC.label) + .should('be.visible') + .click(); + + // Confirm that Subnet selection appears and select mock subnet. + cy.findByLabelText('Subnet').should('be.visible').type(mockSubnet.label); + + ui.autocompletePopper + .findByTitle(`${mockSubnet.label} (${mockSubnet.ipv4})`) + .should('be.visible') + .click(); + + // Confirm VPC assignment indicator is shown in Linode summary. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('VPC Assigned').should('be.visible'); + }); + + // Create Linode and confirm contents of outgoing API request payload. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const expectedVpcInterface = requestPayload['interfaces'][0]; + + // Confirm that request payload includes VPC interface. + expect(expectedVpcInterface['vpc_id']).to.equal(mockVPC.id); + expect(expectedVpcInterface['ipv4']).to.be.an('object').that.is.empty; + expect(expectedVpcInterface['subnet_id']).to.equal(mockSubnet.id); + expect(expectedVpcInterface['purpose']).to.equal('vpc'); + }); + + // Confirm redirect to new Linode. + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // TODO Confirm whether toast notification should appear on Linode create. + }); + + /* + * - Confirms UI flow to create a Linode with a new VPC assigned using mock API data. + * - Creates a VPC and a subnet from within the Linode Create flow. + * - Confirms that Cloud responds gracefully when VPC create API request fails. + * - Confirms that outgoing API request contains correct VPC interface data. + */ + it('can assign new VPCs during Linode Create flow', () => { + const linodeRegion = chooseRegion({ capabilities: ['VPCs'] }); + + const mockErrorMessage = 'An unknown error occurred.'; + + const mockSubnet = subnetFactory.build({ + id: randomNumber(), + label: randomLabel(), + linodes: [], + ipv4: '10.0.0.0/24', + }); + + const mockVPC = vpcFactory.build({ + id: randomNumber(), + description: randomPhrase(), + label: randomLabel(), + region: linodeRegion.id, + subnets: [mockSubnet], + }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + mockGetVPCs([]); + mockCreateLinode(mockLinode).as('createLinode'); + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.setLabel(mockLinode.label); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan('Shared CPU', 'Nanode 1 GB'); + linodeCreatePage.setRootPassword(randomString(32)); + + cy.findByText('Create VPC').should('be.visible').click(); + + ui.drawer + .findByTitle('Create VPC') + .should('be.visible') + .within(() => { + vpcCreateDrawer.setLabel(mockVPC.label); + vpcCreateDrawer.setDescription(mockVPC.description); + vpcCreateDrawer.setSubnetLabel(mockSubnet.label); + vpcCreateDrawer.setSubnetIpRange(mockSubnet.ipv4!); + + // Confirm that unexpected API errors are handled gracefully upon + // failed VPC creation. + mockCreateVPCError(mockErrorMessage, 500).as('createVpc'); + vpcCreateDrawer.submit(); + + cy.wait('@createVpc'); + cy.findByText(mockErrorMessage).scrollIntoView().should('be.visible'); + + // Create VPC with successful API response mocked. + mockCreateVPC(mockVPC).as('createVpc'); + vpcCreateDrawer.submit(); + }); + + // Attempt to create Linode before selecting a VPC subnet, and confirm + // that validation error appears. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByText('Subnet is required.').should('be.visible'); + + // Confirm that Subnet selection appears and select mock subnet. + cy.findByLabelText('Subnet').should('be.visible').type(mockSubnet.label); + + ui.autocompletePopper + .findByTitle(`${mockSubnet.label} (${mockSubnet.ipv4})`) + .should('be.visible') + .click(); + + // Check box to assign public IPv4. + cy.findByText('Assign a public IPv4 address for this Linode') + .should('be.visible') + .click(); + + // Create Linode and confirm contents of outgoing API request payload. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const expectedVpcInterface = requestPayload['interfaces'][0]; + + // Confirm that request payload includes VPC interface. + expect(expectedVpcInterface['vpc_id']).to.equal(mockVPC.id); + expect(expectedVpcInterface['ipv4']).to.deep.equal({ nat_1_1: 'any' }); + expect(expectedVpcInterface['subnet_id']).to.equal(mockSubnet.id); + expect(expectedVpcInterface['purpose']).to.equal('vpc'); + }); + + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // TODO Confirm whether toast notification should appear on Linode create. + }); + + /* + * - Confirms UI flow when attempting to assign VPC to Linode in region without capability. + * - Confirms that VPCs selection is disabled. + * - Confirms that notice text is present to explain that VPCs are unavailable. + */ + it('cannot assign VPCs to Linodes in regions without VPC capability', () => { + const mockRegion = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const vpcNotAvailableMessage = + 'VPC is not available in the selected region.'; + + mockGetRegions([mockRegion]); + cy.visitWithLogin('/linodes/create'); + + linodeCreatePage.selectRegionById(mockRegion.id); + + cy.findByLabelText('Assign VPC') + .scrollIntoView() + .should('be.visible') + .should('be.disabled'); + + cy.findByText(vpcNotAvailableMessage).should('be.visible'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index 025a447e577..92c99fa4884 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -1,590 +1,127 @@ -import { - containsVisible, - fbtClick, - fbtVisible, - getClick, - getVisible, -} from 'support/helpers'; +/** + * @file Linode Create end-to-end tests. + */ + import { ui } from 'support/ui'; -import { apiMatcher } from 'support/util/intercepts'; -import { randomString, randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { getRegionById } from 'support/util/regions'; -import { - subnetFactory, - vpcFactory, - linodeFactory, - linodeConfigFactory, - regionFactory, - VLANFactory, - LinodeConfigInterfaceFactory, - LinodeConfigInterfaceFactoryWithVPC, - accountFactory, -} from '@src/factories'; -import { authenticate } from 'support/api/authentication'; +import { randomLabel, randomString } from 'support/util/random'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { cleanUp } from 'support/util/cleanup'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { - dcPricingPlanPlaceholder, - dcPricingMockLinodeTypes, - dcPricingDocsLabel, - dcPricingDocsUrl, -} from 'support/constants/dc-specific-pricing'; -import { mockGetVLANs } from 'support/intercepts/vlans'; -import { mockGetLinodeConfigs } from 'support/intercepts/configs'; -import { - mockCreateLinode, - mockGetLinodeType, - mockGetLinodeTypes, - mockGetLinodeDisks, - mockGetLinodeVolumes, -} from 'support/intercepts/linodes'; -import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; +import { linodeCreatePage } from 'support/ui/pages'; +import { authenticate } from 'support/api/authentication'; import { mockAppendFeatureFlags, mockGetFeatureFlagClientstream, } from 'support/intercepts/feature-flags'; +import { interceptCreateLinode } from 'support/intercepts/linodes'; import { makeFeatureFlagData } from 'support/util/feature-flags'; -import { - checkboxTestId, - headerTestId, -} from 'src/components/DiskEncryption/DiskEncryption'; - -import type { Config, VLAN, Disk, Region } from '@linode/api-v4'; -import { mockGetAccount } from 'support/intercepts/account'; - -const mockRegions: Region[] = [ - regionFactory.build({ - capabilities: ['Linodes'], - country: 'uk', - id: 'eu-west', - label: 'London, UK', - }), - regionFactory.build({ - capabilities: ['Linodes'], - country: 'sg', - id: 'ap-south', - label: 'Singapore, SG', - }), - regionFactory.build({ - capabilities: ['Linodes'], - id: 'us-east', - label: 'Newark, NJ', - }), - regionFactory.build({ - capabilities: ['Linodes'], - id: 'us-central', - label: 'Dallas, TX', - }), -]; authenticate(); -describe('create linode', () => { +describe('Create Linode', () => { before(() => { cleanUp('linodes'); }); - /* - * Region select test. - * - * TODO: Cypress - * Move this to cypress component testing once the setup is complete - see https://github.com/linode/manager/pull/10134 - * - * - Confirms that region select dropdown is visible and interactive. - * - Confirms that region select dropdown is populated with expected regions. - * - Confirms that region select dropdown is sorted alphabetically by region, with North America first. - * - Confirms that region select dropdown is populated with expected DCs, sorted alphabetically. - */ - it('region select', () => { - mockGetRegions(mockRegions).as('getRegions'); - - cy.visitWithLogin('linodes/create'); - - cy.wait(['@getRegions']); - - // Confirm that region select dropdown is visible and interactive. - ui.regionSelect.find().click(); - cy.get('[data-qa-autocomplete-popper="true"]').should('be.visible'); - - // Confirm that region select dropdown are grouped by region, - // sorted alphabetically, with North America first. - cy.get('.MuiAutocomplete-groupLabel') - .should('have.length', 3) - .should((group) => { - expect(group[0]).to.contain('North America'); - expect(group[1]).to.contain('Asia'); - expect(group[2]).to.contain('Europe'); - }); - - // Confirm that region select dropdown is populated with expected regions, sorted alphabetically. - cy.get('[data-qa-option]').should('exist').should('have.length', 4); - mockRegions.forEach((region) => { - cy.get('[data-qa-option]').contains(region.label); + // Enable the `linodeCreateRefactor` feature flag. + // TODO Delete these mocks once `linodeCreateRefactor` feature flag is retired. + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(true), }); - - // Select an option - cy.findByTestId('eu-west').click(); - // Confirm the popper is closed - cy.get('[data-qa-autocomplete-popper="true"]').should('not.exist'); - // Confirm that the selected region is displayed in the input field. - cy.get('[data-testid="textfield-input"]').should( - 'have.value', - 'London, UK (eu-west)' - ); - - // Confirm that selecting a valid region updates the Plan Selection panel. - expect(cy.get('[data-testid="table-row-empty"]').should('not.exist')); - }); - - it('creates a nanode', () => { - const rootpass = randomString(32); - const linodeLabel = randomLabel(); - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.get('[data-qa-deploy-linode]'); - cy.intercept('POST', apiMatcher('linode/instances')).as('linodeCreated'); - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionLabel(chooseRegion().label).click(); - fbtClick('Shared CPU'); - getClick('[id="g6-nanode-1"]'); - getClick('#linode-label').clear().type(linodeLabel); - cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); - cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - ui.toast.assertMessage(`Your Linode ${linodeLabel} is being created.`); - containsVisible('PROVISIONING'); - fbtVisible(linodeLabel); - cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); - }); - - it('creates a linode via CLI', () => { - const linodeLabel = randomLabel(); - const linodePass = randomString(32); - const linodeRegion = chooseRegion(); - - cy.visitWithLogin('/linodes/create'); - - ui.regionSelect.find().click(); - ui.autocompletePopper - .findByTitle(`${linodeRegion.label} (${linodeRegion.id})`) - .should('exist') - .click(); - - cy.get('[id="g6-dedicated-2"]').click(); - - cy.findByLabelText('Linode Label') - .should('be.visible') - .should('be.enabled') - .clear() - .type(linodeLabel); - - cy.findByLabelText('Root Password') - .should('be.visible') - .should('be.enabled') - .type(linodePass); - - ui.button - .findByTitle('Create using command line') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle('Create Linode') - .should('be.visible') - .within(() => { - // Switch to cURL view if necessary. - cy.findByText('cURL') - .should('be.visible') - .should('have.attr', 'data-selected'); - - // Confirm that cURL command has expected details. - [ - `"region": "${linodeRegion.id}"`, - `"type": "g6-dedicated-2"`, - `"label": "${linodeLabel}"`, - `"root_pass": "${linodePass}"`, - '"booted": true', - ].forEach((line: string) => - cy.findByText(line, { exact: false }).should('be.visible') - ); - - cy.findByText('Linode CLI').should('be.visible').click(); - - [ - `--region ${linodeRegion.id}`, - '--type g6-dedicated-2', - `--label ${linodeLabel}`, - `--root_pass ${linodePass}`, - `--booted true`, - ].forEach((line: string) => cy.contains(line).should('be.visible')); - - ui.buttonGroup - .findButtonByTitle('Close') - .should('be.visible') - .should('be.enabled') - .click(); - }); + mockGetFeatureFlagClientstream(); }); /* - * - Confirms DC-specific pricing UI flow works as expected during Linode creation. - * - Confirms that pricing docs link is shown in "Region" section. - * - Confirms that backups pricing is correct when selecting a region with a different price structure. + * End-to-end tests to create Linodes for each available plan type. */ - it('shows DC-specific pricing information during create flow', () => { - const rootpass = randomString(32); - const linodeLabel = randomLabel(); - const initialRegion = getRegionById('us-west'); - const newRegion = getRegionById('us-east'); - - const mockLinode = linodeFactory.build({ - label: linodeLabel, - region: initialRegion.id, - type: dcPricingMockLinodeTypes[0].id, - }); - - const currentPrice = dcPricingMockLinodeTypes[0].region_prices.find( - (regionPrice) => regionPrice.id === initialRegion.id - )!; - const currentBackupPrice = dcPricingMockLinodeTypes[0].addons.backups.region_prices.find( - (regionPrice) => regionPrice.id === initialRegion.id - )!; - const newPrice = dcPricingMockLinodeTypes[1].region_prices.find( - (linodeType) => linodeType.id === newRegion.id - )!; - const newBackupPrice = dcPricingMockLinodeTypes[1].addons.backups.region_prices.find( - (regionPrice) => regionPrice.id === newRegion.id - )!; - - // Mock requests to get individual types. - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait(['@getLinodeTypes']); - - mockCreateLinode(mockLinode).as('linodeCreated'); - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - getClick('[data-qa-deploy-linode]'); - - // A message is shown to instruct users to select a region in order to view plans and prices - cy.get('[data-qa-tp="Linode Plan"]').should( - 'contain.text', - 'Plan is required.' - ); - cy.get('[data-qa-tp="Linode Plan"]').should( - 'contain.text', - dcPricingPlanPlaceholder - ); - - // Check the 'Backups' add on - cy.get('[data-testid="backups"]').should('be.visible').click(); - ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionLabel(initialRegion.label).click(); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - // Confirm that the backup prices are displayed as expected. - cy.get('[data-qa-add-ons="true"]') - .eq(1) - .within(() => { - cy.findByText(`$${currentBackupPrice.monthly}`).should('be.visible'); - cy.findByText('per month').should('be.visible'); - }); - // Confirm that the checkout summary at the bottom of the page reflects the correct price. - cy.get('[data-qa-summary="true"]').within(() => { - cy.findByText(`$${currentPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - cy.findByText('Backups').should('be.visible'); - cy.findByText(`$${currentBackupPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - }); - - // Confirm there is a docs link to the pricing page. - cy.findByText(dcPricingDocsLabel) - .should('be.visible') - .should('have.attr', 'href', dcPricingDocsUrl); - - ui.regionSelect.find().click().type(`${newRegion.label} {enter}`); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - // Confirm that the backup prices are displayed as expected. - cy.get('[data-qa-add-ons="true"]') - .eq(1) - .within(() => { - cy.findByText(`$${newBackupPrice.monthly}`).should('be.visible'); - cy.findByText('per month').should('be.visible'); + describe('End-to-end', () => { + // Run an end-to-end test to create a basic Linode for each plan type described below. + describe('By plan type', () => { + [ + { + planType: 'Shared CPU', + planLabel: 'Nanode 1 GB', + planId: 'g6-nanode-1', + }, + { + planType: 'Dedicated CPU', + planLabel: 'Dedicated 4 GB', + planId: 'g6-dedicated-2', + }, + { + planType: 'High Memory', + planLabel: 'Linode 24 GB', + planId: 'g7-highmem-1', + }, + { + planType: 'Premium CPU', + planLabel: 'Premium 4 GB', + planId: 'g7-premium-2', + }, + // TODO Include GPU plan types. + ].forEach((planConfig) => { + /* + * - Parameterized end-to-end test to create a Linode for each plan type. + * - Confirms that a Linode of the given plan type can be deployed. + */ + it(`creates a ${planConfig.planType} Linode`, () => { + const linodeRegion = chooseRegion({ + capabilities: ['Linodes', 'Premium Plans'], + }); + const linodeLabel = randomLabel(); + + interceptCreateLinode().as('createLinode'); + cy.visitWithLogin('/linodes/create'); + + // Set Linode label, distribution, plan type, password, etc. + linodeCreatePage.setLabel(linodeLabel); + linodeCreatePage.selectImage('Debian 11'); + linodeCreatePage.selectRegionById(linodeRegion.id); + linodeCreatePage.selectPlan( + planConfig.planType, + planConfig.planLabel + ); + linodeCreatePage.setRootPassword(randomString(32)); + + // Confirm information in summary is shown as expected. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('Debian 11').should('be.visible'); + cy.findByText(linodeRegion.label).should('be.visible'); + cy.findByText(planConfig.planLabel).should('be.visible'); + }); + + // Create Linode and confirm it's provisioned as expected. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createLinode').then((xhr) => { + const requestPayload = xhr.request.body; + const responsePayload = xhr.response?.body; + + // Confirm that API request and response contain expected data + expect(requestPayload['label']).to.equal(linodeLabel); + expect(requestPayload['region']).to.equal(linodeRegion.id); + expect(requestPayload['type']).to.equal(planConfig.planId); + + expect(responsePayload['label']).to.equal(linodeLabel); + expect(responsePayload['region']).to.equal(linodeRegion.id); + expect(responsePayload['type']).to.equal(planConfig.planId); + + // Confirm that Cloud redirects to details page + cy.url().should('endWith', `/linodes/${responsePayload['id']}`); + }); + + // TODO Confirm whether or not toast notification should appear here. + cy.findByText('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( + 'be.visible' + ); + }); }); - // Confirms that the summary updates to reflect price changes if the user changes their region and plan selection. - cy.get('[data-qa-summary="true"]').within(() => { - cy.findByText(`$${newPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - cy.findByText('Backups').should('be.visible'); - cy.findByText(`$${newBackupPrice.monthly!.toFixed(2)}/month`).should( - 'be.visible' - ); - }); - - getClick('#linode-label').clear().type(linodeLabel); - cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); - cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - fbtVisible(linodeLabel); - cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); - }); - - it("prevents a VPC from being assigned in a region that doesn't support VPCs during the Linode Create flow", () => { - const region: Region = getRegionById('us-southeast'); - const mockNoVPCRegion = regionFactory.build({ - id: region.id, - label: region.label, - capabilities: ['Linodes'], - }); - - // Mock requests to get individual types. - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - mockGetRegions([mockNoVPCRegion]).as('getRegions'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait(['@getLinodeTypes', '@getClientStream', '@getFeatureFlags']); - - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - - // Check the 'Backups' add on - cy.get('[data-testid="backups"]').should('be.visible').click(); - ui.regionSelect.find().click().type(`${region.label} {enter}`); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - - // the "VPC" section is present - getVisible('[data-testid="vpc-panel"]').within(() => { - containsVisible( - 'Allow Linode to communicate in an isolated environment.' - ); - // Helper text appears if VPC is not available in selected region. - containsVisible('VPC is not available in the selected region.'); - }); - }); - - it('assigns a VPC to the linode during create flow', () => { - const rootpass = randomString(32); - const linodeLabel = randomLabel(); - const region: Region = getRegionById('us-southeast'); - const diskLabel: string = 'Debian 10 Disk'; - const mockLinode = linodeFactory.build({ - label: linodeLabel, - region: region.id, - type: dcPricingMockLinodeTypes[0].id, - }); - const mockVLANs: VLAN[] = VLANFactory.buildList(2); - const mockSubnet = subnetFactory.build({ - id: randomNumber(2), - label: randomLabel(), - }); - const mockVPC = vpcFactory.build({ - id: randomNumber(), - region: 'us-southeast', - subnets: [mockSubnet], - }); - const mockVPCRegion = regionFactory.build({ - id: region.id, - label: region.label, - capabilities: ['Linodes', 'VPCs', 'Vlans'], - }); - const mockPublicConfigInterface = LinodeConfigInterfaceFactory.build({ - ipam_address: null, - purpose: 'public', }); - const mockVlanConfigInterface = LinodeConfigInterfaceFactory.build(); - const mockVpcConfigInterface = LinodeConfigInterfaceFactoryWithVPC.build({ - vpc_id: mockVPC.id, - purpose: 'vpc', - active: true, - }); - const mockConfig: Config = linodeConfigFactory.build({ - id: randomNumber(), - interfaces: [ - // The order of this array is significant. Index 0 (eth0) should be public. - mockPublicConfigInterface, - mockVlanConfigInterface, - mockVpcConfigInterface, - ], - }); - const mockDisks: Disk[] = [ - { - id: 44311273, - status: 'ready', - label: diskLabel, - created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:30', - filesystem: 'ext4', - size: 81408, - }, - { - id: 44311274, - status: 'ready', - label: '512 MB Swap Image', - created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:31', - filesystem: 'swap', - size: 512, - }, - ]; - - // Mock requests to get individual types. - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); - - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - mockGetRegions([mockVPCRegion]).as('getRegions'); - - mockGetVLANs(mockVLANs); - mockGetVPC(mockVPC).as('getVPC'); - mockGetVPCs([mockVPC]).as('getVPCs'); - mockCreateLinode(mockLinode).as('linodeCreated'); - mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as('getLinodeConfigs'); - mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks'); - mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait([ - '@getLinodeTypes', - '@getClientStream', - '@getFeatureFlags', - '@getVPCs', - ]); - - cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); - - // Check the 'Backups' add on - cy.get('[data-testid="backups"]').should('be.visible').click(); - ui.regionSelect.find().click().type(`${region.label} {enter}`); - fbtClick('Shared CPU'); - getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); - - // the "VPC" section is present, and the VPC in the same region of - // the linode can be selected. - getVisible('[data-testid="vpc-panel"]').within(() => { - containsVisible('Assign this Linode to an existing VPC.'); - // select VPC - cy.get('[data-qa-enhanced-select="None"]') - .should('be.visible') - .click() - .type(`${mockVPC.label}{enter}`); - // select subnet - cy.findByText('Select Subnet') - .should('be.visible') - .click() - .type(`${mockSubnet.label}{enter}`); - }); - - getClick('#linode-label').clear().type(linodeLabel); - cy.get('#root-password').type(rootpass); - getClick('[data-qa-deploy-linode]'); - cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); - fbtVisible(linodeLabel); - cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); - - fbtClick('Configurations'); - //cy.wait(['@getLinodeConfigs', '@getVPC', '@getDisks', '@getVolumes']); - - // Confirm that VLAN and VPC have been assigned. - cy.findByLabelText('List of Configurations').within(() => { - cy.get('tr').should('have.length', 2); - containsVisible(`${mockConfig.label} – GRUB 2`); - containsVisible('eth0 – Public Internet'); - containsVisible(`eth2 – VPC: ${mockVPC.label}`); - }); - }); - - it('should not have a "Disk Encryption" section visible if the feature flag is off and user does not have capability', () => { - // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out - mockAppendFeatureFlags({ - linodeDiskEncryption: makeFeatureFlagData(false), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - // Mock account response - const mockAccount = accountFactory.build({ - capabilities: ['Linodes'], - }); - - mockGetAccount(mockAccount).as('getAccount'); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); - - // Check if section is visible - cy.get(`[data-testid=${headerTestId}]`).should('not.exist'); - }); - - it('should have a "Disk Encryption" section visible if feature flag is on and user has the capability', () => { - // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out - mockAppendFeatureFlags({ - linodeDiskEncryption: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - mockGetFeatureFlagClientstream().as('getClientStream'); - - // Mock account response - const mockAccount = accountFactory.build({ - capabilities: ['Linodes', 'Disk Encryption'], - }); - - const mockRegion = regionFactory.build({ - capabilities: ['Linodes', 'Disk Encryption'], - }); - - const mockRegionWithoutDiskEncryption = regionFactory.build({ - capabilities: ['Linodes'], - }); - - const mockRegions = [mockRegion, mockRegionWithoutDiskEncryption]; - - mockGetAccount(mockAccount).as('getAccount'); - mockGetRegions(mockRegions); - - // intercept request - cy.visitWithLogin('/linodes/create'); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); - - // Check if section is visible - cy.get(`[data-testid="${headerTestId}"]`).should('exist'); - - // "Encrypt Disk" checkbox should be disabled if a region that does not support LDE is selected - ui.regionSelect.find().click(); - ui.select - .findItemByText( - `${mockRegionWithoutDiskEncryption.label} (${mockRegionWithoutDiskEncryption.id})` - ) - .click(); - - cy.get(`[data-testid="${checkboxTestId}"]`).should('be.disabled'); - - ui.regionSelect.find().click(); - ui.select.findItemByText(`${mockRegion.label} (${mockRegion.id})`).click(); - - cy.get(`[data-testid="${checkboxTestId}"]`).should('be.enabled'); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts new file mode 100644 index 00000000000..0f9e5fc2238 --- /dev/null +++ b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts @@ -0,0 +1,603 @@ +/** + * @file Integration tests and end-to-end tests for legacy Linode Create flow. + */ +// TODO Delete this test file when `linodeCreateRefactor` feature flag is retired. +// Move out any tests (e.g. region select test) for flows that aren't covered by new tests in the meantime. + +import { + containsVisible, + fbtClick, + fbtVisible, + getClick, + getVisible, +} from 'support/helpers'; +import { ui } from 'support/ui'; +import { apiMatcher } from 'support/util/intercepts'; +import { randomString, randomLabel, randomNumber } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; +import { getRegionById } from 'support/util/regions'; +import { + accountFactory, + subnetFactory, + vpcFactory, + linodeFactory, + linodeConfigFactory, + regionFactory, + VLANFactory, + LinodeConfigInterfaceFactory, + LinodeConfigInterfaceFactoryWithVPC, +} from '@src/factories'; +import { authenticate } from 'support/api/authentication'; +import { cleanUp } from 'support/util/cleanup'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + dcPricingPlanPlaceholder, + dcPricingMockLinodeTypes, + dcPricingDocsLabel, + dcPricingDocsUrl, +} from 'support/constants/dc-specific-pricing'; +import { mockGetVLANs } from 'support/intercepts/vlans'; +import { mockGetLinodeConfigs } from 'support/intercepts/configs'; +import { + mockCreateLinode, + mockGetLinodeType, + mockGetLinodeTypes, + mockGetLinodeDisks, + mockGetLinodeVolumes, +} from 'support/intercepts/linodes'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { + checkboxTestId, + headerTestId, +} from 'src/components/DiskEncryption/DiskEncryption'; + +import type { Config, VLAN, Disk, Region } from '@linode/api-v4'; + +const mockRegions: Region[] = [ + regionFactory.build({ + capabilities: ['Linodes'], + country: 'uk', + id: 'eu-west', + label: 'London, UK', + }), + regionFactory.build({ + capabilities: ['Linodes'], + country: 'sg', + id: 'ap-south', + label: 'Singapore, SG', + }), + regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-east', + label: 'Newark, NJ', + }), + regionFactory.build({ + capabilities: ['Linodes'], + id: 'us-central', + label: 'Dallas, TX', + }), +]; + +authenticate(); +describe('create linode', () => { + before(() => { + cleanUp('linodes'); + }); + + beforeEach(() => { + mockAppendFeatureFlags({ + linodeCreateRefactor: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + }); + + /* + * Region select test. + * + * TODO: Cypress + * Move this to cypress component testing once the setup is complete - see https://github.com/linode/manager/pull/10134 + * + * - Confirms that region select dropdown is visible and interactive. + * - Confirms that region select dropdown is populated with expected regions. + * - Confirms that region select dropdown is sorted alphabetically by region, with North America first. + * - Confirms that region select dropdown is populated with expected DCs, sorted alphabetically. + */ + it('region select', () => { + mockGetRegions(mockRegions).as('getRegions'); + + cy.visitWithLogin('linodes/create'); + + cy.wait(['@getRegions']); + + // Confirm that region select dropdown is visible and interactive. + ui.regionSelect.find().click(); + cy.get('[data-qa-autocomplete-popper="true"]').should('be.visible'); + + // Confirm that region select dropdown are grouped by region, + // sorted alphabetically, with North America first. + cy.get('.MuiAutocomplete-groupLabel') + .should('have.length', 3) + .should((group) => { + expect(group[0]).to.contain('North America'); + expect(group[1]).to.contain('Asia'); + expect(group[2]).to.contain('Europe'); + }); + + // Confirm that region select dropdown is populated with expected regions, sorted alphabetically. + cy.get('[data-qa-option]').should('exist').should('have.length', 4); + mockRegions.forEach((region) => { + cy.get('[data-qa-option]').contains(region.label); + }); + + // Select an option + cy.findByTestId('eu-west').click(); + // Confirm the popper is closed + cy.get('[data-qa-autocomplete-popper="true"]').should('not.exist'); + // Confirm that the selected region is displayed in the input field. + cy.get('[data-testid="textfield-input"]').should( + 'have.value', + 'London, UK (eu-west)' + ); + + // Confirm that selecting a valid region updates the Plan Selection panel. + expect(cy.get('[data-testid="table-row-empty"]').should('not.exist')); + }); + + it('creates a nanode', () => { + const rootpass = randomString(32); + const linodeLabel = randomLabel(); + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.get('[data-qa-deploy-linode]'); + cy.intercept('POST', apiMatcher('linode/instances')).as('linodeCreated'); + cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(chooseRegion().label).click(); + fbtClick('Shared CPU'); + getClick('[id="g6-nanode-1"]'); + getClick('#linode-label').clear().type(linodeLabel); + cy.get('#root-password').type(rootpass); + getClick('[data-qa-deploy-linode]'); + cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); + ui.toast.assertMessage(`Your Linode ${linodeLabel} is being created.`); + containsVisible('PROVISIONING'); + fbtVisible(linodeLabel); + cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); + }); + + it('creates a linode via CLI', () => { + const linodeLabel = randomLabel(); + const linodePass = randomString(32); + const linodeRegion = chooseRegion(); + + cy.visitWithLogin('/linodes/create'); + + ui.regionSelect.find().click(); + ui.autocompletePopper + .findByTitle(`${linodeRegion.label} (${linodeRegion.id})`) + .should('exist') + .click(); + + cy.get('[id="g6-dedicated-2"]').click(); + + cy.findByLabelText('Linode Label') + .should('be.visible') + .should('be.enabled') + .clear() + .type(linodeLabel); + + cy.findByLabelText('Root Password') + .should('be.visible') + .should('be.enabled') + .type(linodePass); + + ui.button + .findByTitle('Create using command line') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.dialog + .findByTitle('Create Linode') + .should('be.visible') + .within(() => { + // Switch to cURL view if necessary. + cy.findByText('cURL') + .should('be.visible') + .should('have.attr', 'data-selected'); + + // Confirm that cURL command has expected details. + [ + `"region": "${linodeRegion.id}"`, + `"type": "g6-dedicated-2"`, + `"label": "${linodeLabel}"`, + `"root_pass": "${linodePass}"`, + '"booted": true', + ].forEach((line: string) => + cy.findByText(line, { exact: false }).should('be.visible') + ); + + cy.findByText('Linode CLI').should('be.visible').click(); + + [ + `--region ${linodeRegion.id}`, + '--type g6-dedicated-2', + `--label ${linodeLabel}`, + `--root_pass ${linodePass}`, + `--booted true`, + ].forEach((line: string) => cy.contains(line).should('be.visible')); + + ui.buttonGroup + .findButtonByTitle('Close') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + /* + * - Confirms DC-specific pricing UI flow works as expected during Linode creation. + * - Confirms that pricing docs link is shown in "Region" section. + * - Confirms that backups pricing is correct when selecting a region with a different price structure. + */ + it('shows DC-specific pricing information during create flow', () => { + const rootpass = randomString(32); + const linodeLabel = randomLabel(); + const initialRegion = getRegionById('us-west'); + const newRegion = getRegionById('us-east'); + + const mockLinode = linodeFactory.build({ + label: linodeLabel, + region: initialRegion.id, + type: dcPricingMockLinodeTypes[0].id, + }); + + const currentPrice = dcPricingMockLinodeTypes[0].region_prices.find( + (regionPrice) => regionPrice.id === initialRegion.id + )!; + const currentBackupPrice = dcPricingMockLinodeTypes[0].addons.backups.region_prices.find( + (regionPrice) => regionPrice.id === initialRegion.id + )!; + const newPrice = dcPricingMockLinodeTypes[1].region_prices.find( + (linodeType) => linodeType.id === newRegion.id + )!; + const newBackupPrice = dcPricingMockLinodeTypes[1].addons.backups.region_prices.find( + (regionPrice) => regionPrice.id === newRegion.id + )!; + + // Mock requests to get individual types. + mockGetLinodeType(dcPricingMockLinodeTypes[0]); + mockGetLinodeType(dcPricingMockLinodeTypes[1]); + mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getLinodeTypes']); + + mockCreateLinode(mockLinode).as('linodeCreated'); + cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + getClick('[data-qa-deploy-linode]'); + + // A message is shown to instruct users to select a region in order to view plans and prices + cy.get('[data-qa-tp="Linode Plan"]').should( + 'contain.text', + 'Plan is required.' + ); + cy.get('[data-qa-tp="Linode Plan"]').should( + 'contain.text', + dcPricingPlanPlaceholder + ); + + // Check the 'Backups' add on + cy.get('[data-testid="backups"]').should('be.visible').click(); + ui.regionSelect.find().click(); + ui.regionSelect.findItemByRegionLabel(initialRegion.label).click(); + fbtClick('Shared CPU'); + getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + // Confirm that the backup prices are displayed as expected. + cy.get('[data-qa-add-ons="true"]') + .eq(1) + .within(() => { + cy.findByText(`$${currentBackupPrice.monthly}`).should('be.visible'); + cy.findByText('per month').should('be.visible'); + }); + // Confirm that the checkout summary at the bottom of the page reflects the correct price. + cy.get('[data-qa-summary="true"]').within(() => { + cy.findByText(`$${currentPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + cy.findByText('Backups').should('be.visible'); + cy.findByText(`$${currentBackupPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + }); + + // Confirm there is a docs link to the pricing page. + cy.findByText(dcPricingDocsLabel) + .should('be.visible') + .should('have.attr', 'href', dcPricingDocsUrl); + + ui.regionSelect.find().click().type(`${newRegion.label} {enter}`); + fbtClick('Shared CPU'); + getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + // Confirm that the backup prices are displayed as expected. + cy.get('[data-qa-add-ons="true"]') + .eq(1) + .within(() => { + cy.findByText(`$${newBackupPrice.monthly}`).should('be.visible'); + cy.findByText('per month').should('be.visible'); + }); + // Confirms that the summary updates to reflect price changes if the user changes their region and plan selection. + cy.get('[data-qa-summary="true"]').within(() => { + cy.findByText(`$${newPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + cy.findByText('Backups').should('be.visible'); + cy.findByText(`$${newBackupPrice.monthly!.toFixed(2)}/month`).should( + 'be.visible' + ); + }); + + getClick('#linode-label').clear().type(linodeLabel); + cy.get('#root-password').type(rootpass); + getClick('[data-qa-deploy-linode]'); + cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); + fbtVisible(linodeLabel); + cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); + }); + + it("prevents a VPC from being assigned in a region that doesn't support VPCs during the Linode Create flow", () => { + const region: Region = getRegionById('us-southeast'); + const mockNoVPCRegion = regionFactory.build({ + id: region.id, + label: region.label, + capabilities: ['Linodes'], + }); + + // Mock requests to get individual types. + mockGetLinodeType(dcPricingMockLinodeTypes[0]); + mockGetLinodeType(dcPricingMockLinodeTypes[1]); + mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); + + mockAppendFeatureFlags({ + vpc: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + mockGetRegions([mockNoVPCRegion]).as('getRegions'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getLinodeTypes', '@getClientStream', '@getFeatureFlags']); + + cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + + // Check the 'Backups' add on + cy.get('[data-testid="backups"]').should('be.visible').click(); + ui.regionSelect.find().click().type(`${region.label} {enter}`); + fbtClick('Shared CPU'); + getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + + // the "VPC" section is present + getVisible('[data-testid="vpc-panel"]').within(() => { + containsVisible( + 'Allow Linode to communicate in an isolated environment.' + ); + // Helper text appears if VPC is not available in selected region. + containsVisible('VPC is not available in the selected region.'); + }); + }); + + it('assigns a VPC to the linode during create flow', () => { + const rootpass = randomString(32); + const linodeLabel = randomLabel(); + const region: Region = getRegionById('us-southeast'); + const diskLabel: string = 'Debian 10 Disk'; + const mockLinode = linodeFactory.build({ + label: linodeLabel, + region: region.id, + type: dcPricingMockLinodeTypes[0].id, + }); + const mockVLANs: VLAN[] = VLANFactory.buildList(2); + const mockSubnet = subnetFactory.build({ + id: randomNumber(2), + label: randomLabel(), + }); + const mockVPC = vpcFactory.build({ + id: randomNumber(), + region: 'us-southeast', + subnets: [mockSubnet], + }); + const mockVPCRegion = regionFactory.build({ + id: region.id, + label: region.label, + capabilities: ['Linodes', 'VPCs', 'Vlans'], + }); + const mockPublicConfigInterface = LinodeConfigInterfaceFactory.build({ + ipam_address: null, + purpose: 'public', + }); + const mockVlanConfigInterface = LinodeConfigInterfaceFactory.build(); + const mockVpcConfigInterface = LinodeConfigInterfaceFactoryWithVPC.build({ + vpc_id: mockVPC.id, + purpose: 'vpc', + active: true, + }); + const mockConfig: Config = linodeConfigFactory.build({ + id: randomNumber(), + interfaces: [ + // The order of this array is significant. Index 0 (eth0) should be public. + mockPublicConfigInterface, + mockVlanConfigInterface, + mockVpcConfigInterface, + ], + }); + const mockDisks: Disk[] = [ + { + id: 44311273, + status: 'ready', + label: diskLabel, + created: '2020-08-21T17:26:14', + updated: '2020-08-21T17:26:30', + filesystem: 'ext4', + size: 81408, + }, + { + id: 44311274, + status: 'ready', + label: '512 MB Swap Image', + created: '2020-08-21T17:26:14', + updated: '2020-08-21T17:26:31', + filesystem: 'swap', + size: 512, + }, + ]; + + // Mock requests to get individual types. + mockGetLinodeType(dcPricingMockLinodeTypes[0]); + mockGetLinodeType(dcPricingMockLinodeTypes[1]); + mockGetLinodeTypes(dcPricingMockLinodeTypes).as('getLinodeTypes'); + + mockAppendFeatureFlags({ + vpc: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + mockGetRegions([mockVPCRegion]).as('getRegions'); + + mockGetVLANs(mockVLANs); + mockGetVPC(mockVPC).as('getVPC'); + mockGetVPCs([mockVPC]).as('getVPCs'); + mockCreateLinode(mockLinode).as('linodeCreated'); + mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as('getLinodeConfigs'); + mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks'); + mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait([ + '@getLinodeTypes', + '@getClientStream', + '@getFeatureFlags', + '@getVPCs', + ]); + + cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); + + // Check the 'Backups' add on + cy.get('[data-testid="backups"]').should('be.visible').click(); + ui.regionSelect.find().click().type(`${region.label} {enter}`); + fbtClick('Shared CPU'); + getClick(`[id="${dcPricingMockLinodeTypes[0].id}"]`); + + // the "VPC" section is present, and the VPC in the same region of + // the linode can be selected. + getVisible('[data-testid="vpc-panel"]').within(() => { + containsVisible('Assign this Linode to an existing VPC.'); + // select VPC + cy.get('[data-qa-enhanced-select="None"]') + .should('be.visible') + .click() + .type(`${mockVPC.label}{enter}`); + // select subnet + cy.findByText('Select Subnet') + .should('be.visible') + .click() + .type(`${mockSubnet.label}{enter}`); + }); + + getClick('#linode-label').clear().type(linodeLabel); + cy.get('#root-password').type(rootpass); + getClick('[data-qa-deploy-linode]'); + cy.wait('@linodeCreated').its('response.statusCode').should('eq', 200); + fbtVisible(linodeLabel); + cy.contains('RUNNING', { timeout: 300000 }).should('be.visible'); + + fbtClick('Configurations'); + //cy.wait(['@getLinodeConfigs', '@getVPC', '@getDisks', '@getVolumes']); + + // Confirm that VLAN and VPC have been assigned. + cy.findByLabelText('List of Configurations').within(() => { + cy.get('tr').should('have.length', 2); + containsVisible(`${mockConfig.label} – GRUB 2`); + containsVisible('eth0 – Public Internet'); + containsVisible(`eth2 – VPC: ${mockVPC.label}`); + }); + }); + + it('should not have a "Disk Encryption" section visible if the feature flag is off and user does not have capability', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(false), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Mock account response + const mockAccount = accountFactory.build({ + capabilities: ['Linodes'], + }); + + mockGetAccount(mockAccount).as('getAccount'); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); + + // Check if section is visible + cy.get(`[data-testid=${headerTestId}]`).should('not.exist'); + }); + + it('should have a "Disk Encryption" section visible if feature flag is on and user has the capability', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Mock account response + const mockAccount = accountFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + const mockRegion = regionFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + const mockRegionWithoutDiskEncryption = regionFactory.build({ + capabilities: ['Linodes'], + }); + + const mockRegions = [mockRegion, mockRegionWithoutDiskEncryption]; + + mockGetAccount(mockAccount).as('getAccount'); + mockGetRegions(mockRegions); + + // intercept request + cy.visitWithLogin('/linodes/create'); + cy.wait(['@getFeatureFlags', '@getClientStream', '@getAccount']); + + // Check if section is visible + cy.get(`[data-testid="${headerTestId}"]`).should('exist'); + + // "Encrypt Disk" checkbox should be disabled if a region that does not support LDE is selected + ui.regionSelect.find().click(); + ui.select + .findItemByText( + `${mockRegionWithoutDiskEncryption.label} (${mockRegionWithoutDiskEncryption.id})` + ) + .click(); + + cy.get(`[data-testid="${checkboxTestId}"]`).should('be.disabled'); + + ui.regionSelect.find().click(); + ui.select.findItemByText(`${mockRegion.label} (${mockRegion.id})`).click(); + + cy.get(`[data-testid="${checkboxTestId}"]`).should('be.enabled'); + }); +}); diff --git a/packages/manager/cypress/fixtures/user-data/user-data-config-basic.yml b/packages/manager/cypress/fixtures/user-data/user-data-config-basic.yml new file mode 100644 index 00000000000..b8bc4e3163e --- /dev/null +++ b/packages/manager/cypress/fixtures/user-data/user-data-config-basic.yml @@ -0,0 +1,11 @@ +#cloud-config + +# Sample cloud-init config data file. +# See also: https://cloudinit.readthedocs.io/en/latest/explanation/format.html + +groups: + - foo-group + +users: + - name: foo + primary_group: foo-group diff --git a/packages/manager/cypress/support/constants/environment.ts b/packages/manager/cypress/support/constants/environment.ts new file mode 100644 index 00000000000..545bcd9bc8a --- /dev/null +++ b/packages/manager/cypress/support/constants/environment.ts @@ -0,0 +1,21 @@ +/** + * @file Constants related to test environment. + */ + +export interface ViewportSize { + width: number; + height: number; + label?: string; +} + +// Array of common mobile viewports against which to test. +export const MOBILE_VIEWPORTS: ViewportSize[] = [ + { + // iPhone 14 Pro, iPhone 15, iPhone 15 Pro, etc. + label: 'iPhone 15', + width: 393, + height: 852, + }, + // TODO Evaluate what devices to include here and how long to allow this list to be. Tablets? + // Do we want to keep this short, or make it long and just choose a random subset each time we do mobile testing? +]; diff --git a/packages/manager/cypress/support/constants/linodes.ts b/packages/manager/cypress/support/constants/linodes.ts new file mode 100644 index 00000000000..f6eb377242c --- /dev/null +++ b/packages/manager/cypress/support/constants/linodes.ts @@ -0,0 +1,10 @@ +/** + * @file Constants related to Linode tests. + */ + +/** + * Length of time to wait for a Linode to be created. + * + * Equals 3 minutes. + */ +export const LINODE_CREATE_TIMEOUT = 180_000; diff --git a/packages/manager/cypress/support/intercepts/images.ts b/packages/manager/cypress/support/intercepts/images.ts index 244e969867a..a9a38804c06 100644 --- a/packages/manager/cypress/support/intercepts/images.ts +++ b/packages/manager/cypress/support/intercepts/images.ts @@ -2,12 +2,12 @@ * @file Cypress intercepts and mocks for Image API requests. */ -import { imageFactory } from '@src/factories'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { getFilters } from 'support/util/request'; -import type { Image, ImageStatus } from '@linode/api-v4'; +import type { Image } from '@linode/api-v4'; +import { makeResponse } from 'support/util/response'; /** * Intercepts POST request to create a machine image and mocks the response. @@ -92,20 +92,15 @@ export const mockGetRecoveryImages = ( * @returns Cypress chainable. */ export const mockGetImage = ( - label: string, - id: string, - status: ImageStatus + imageId: string, + image: Image ): Cypress.Chainable => { - const encodedId = encodeURIComponent(id); - return cy.intercept('GET', apiMatcher(`images/${encodedId}*`), (req) => { - return req.reply( - imageFactory.build({ - id, - label, - status, - }) - ); - }); + const encodedId = encodeURIComponent(imageId); + return cy.intercept( + 'GET', + apiMatcher(`images/${encodedId}*`), + makeResponse(image) + ); }; /** diff --git a/packages/manager/cypress/support/intercepts/profile.ts b/packages/manager/cypress/support/intercepts/profile.ts index 3ed9a751483..f9053602749 100644 --- a/packages/manager/cypress/support/intercepts/profile.ts +++ b/packages/manager/cypress/support/intercepts/profile.ts @@ -13,6 +13,7 @@ import type { Profile, SecurityQuestionsData, SecurityQuestionsPayload, + SSHKey, Token, UserPreferences, } from '@linode/api-v4'; @@ -388,3 +389,48 @@ export const mockResetOAuthApps = ( oauthApp ); }; + +/** + * Intercepts GET request to fetch SSH keys and mocks the response. + * + * @param sshKeys - Array of SSH key objects with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetSSHKeys = (sshKeys: SSHKey[]): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('/profile/sshkeys*'), + paginateResponse(sshKeys) + ); +}; + +/** + * Intercepts GET request to fetch an SSH key and mocks the response. + * + * @param sshKey - SSH key object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetSSHKey = (sshKey: SSHKey): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`/profile/sshkeys/${sshKey.id}`), + makeResponse(sshKey) + ); +}; + +/** + * Intercepts POST request to create an SSH key and mocks the response. + * + * @param sshKey - SSH key object with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockCreateSSHKey = (sshKey: SSHKey): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher('/profile/sshkeys'), + makeResponse(sshKey) + ); +}; diff --git a/packages/manager/cypress/support/ui/accordion.ts b/packages/manager/cypress/support/ui/accordion.ts index 523de4ed3fa..31e8f25cd32 100644 --- a/packages/manager/cypress/support/ui/accordion.ts +++ b/packages/manager/cypress/support/ui/accordion.ts @@ -1,3 +1,23 @@ +/** + * UI helpers for accordion panel headings. + */ +export const accordionHeading = { + /** + * Finds an accordion with the given title. + * + * @param title - Title of the accordion header to find. + * + * @returns Cypress chainable. + */ + findByTitle: (title: string) => { + // We have to rely on the selector because some accordion titles contain + // other React components within them. + return cy.findByText(title, { + selector: '[data-qa-panel-subheading], [data-qa-panel-subheading] *', + }); + }, +}; + /** * UI helpers for accordion panels. */ @@ -19,6 +39,13 @@ export const accordion = { * @returns Cypress chainable. */ findByTitle: (title: string) => { - return cy.get(`[data-qa-panel="${title}"]`).find('[data-qa-panel-details]'); + // We have to rely on the selector because some accordion titles contain + // other React components within them. + return cy + .findByText(title, { + selector: '[data-qa-panel-subheading], [data-qa-panel-subheading] *', + }) + .closest('[data-qa-panel]') + .find('[data-qa-panel-details]'); }, }; diff --git a/packages/manager/cypress/support/ui/pages/index.ts b/packages/manager/cypress/support/ui/pages/index.ts new file mode 100644 index 00000000000..f08f42cd5a9 --- /dev/null +++ b/packages/manager/cypress/support/ui/pages/index.ts @@ -0,0 +1,15 @@ +/** + * @file Index file for Cypress page utility re-exports. + * + * Page utilities are basic JavaScript objects containing functions to perform + * common page-specific interactions. They allow us to minimize code duplication + * across tests that interact with similar pages. + * + * Page utilities are NOT page objects in the traditional UI testing sense. + * Specifically, page utility objects should NOT have state, and page utilities + * should only be concerned with interacting with or asserting the state of + * the DOM. + */ + +export * from './linode-create-page'; +export * from './vpc-create-drawer'; diff --git a/packages/manager/cypress/support/ui/pages/linode-create-page.ts b/packages/manager/cypress/support/ui/pages/linode-create-page.ts new file mode 100644 index 00000000000..1f24a0899ca --- /dev/null +++ b/packages/manager/cypress/support/ui/pages/linode-create-page.ts @@ -0,0 +1,94 @@ +/** + * @file Page utilities for Linode Create page (v2 implementation). + */ + +import { ui } from 'support/ui'; + +/** + * Page utilities for interacting with the Linode create page. + */ +export const linodeCreatePage = { + /** + * Sets the Linode's label. + * + * @param linodeLabel - Linode label to set. + */ + setLabel: (linodeLabel: string) => { + cy.findByLabelText('Linode Label').type(`{selectall}{del}${linodeLabel}`); + }, + + /** + * Sets the Linode's root password. + * + * @param linodePassword - Root password to set. + */ + setRootPassword: (linodePassword: string) => { + cy.findByLabelText('Root Password').as('rootPasswordField').click(); + + cy.get('@rootPasswordField').type(linodePassword, { log: false }); + }, + + /** + * Selects the Image with the given name. + * + * @param imageName - Name of Image to select. + */ + selectImage: (imageName: string) => { + cy.findByText('Choose a Distribution') + .closest('[data-qa-paper]') + .within(() => { + ui.autocomplete.find().click(); + + ui.autocompletePopper + .findByTitle(imageName) + .should('be.visible') + .click(); + }); + }, + + /** + * Select the Region with the given ID. + * + * @param regionId - ID of Region to select. + */ + selectRegionById: (regionId: string) => { + ui.regionSelect.find().click().type(`${regionId}{enter}`); + }, + + /** + * Select the given Linode plan. + * + * Assumes that plans are displayed in a table. + * + * @param planTabTitle - Title of tab where desired plan is located. + * @param planTitle - Title of desired plan. + */ + selectPlan: (planTabTitle: string, planTitle: string) => { + cy.get('[data-qa-tp="Linode Plan"]').within(() => { + ui.tabList.findTabByTitle(planTabTitle).click(); + cy.get(`[data-qa-plan-row="${planTitle}"]`) + .closest('tr') + .should('be.visible') + .click(); + }); + }, + + /** + * Select the given Linode plan selection card. + * + * Useful for testing Linode create page against mobile viewports. + * + * Assumes that plans are displayed as selection cards. + */ + selectPlanCard: (planTabTitle: string, planTitle: string) => { + cy.get('[data-qa-tp="Linode Plan"]').within(() => { + ui.tabList.findTabByTitle(planTabTitle).click(); + cy.findByText(planTitle) + .should('be.visible') + .as('selectionCard') + .scrollIntoView(); + + cy.get('@selectionCard').click(); + }); + }, +}; diff --git a/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts b/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts new file mode 100644 index 00000000000..20c5635dc4b --- /dev/null +++ b/packages/manager/cypress/support/ui/pages/vpc-create-drawer.ts @@ -0,0 +1,80 @@ +import { ui } from 'support/ui'; + +/** + * Page utilities for interacting with the VPC create drawer. + * + * Assumes that selection context is limited to only the drawer. + */ +export const vpcCreateDrawer = { + /** + * Sets the VPC create form's label field. + * + * @param vpcLabel - VPC label to set. + */ + setLabel: (vpcLabel: string) => { + cy.findByLabelText('VPC Label') + .should('be.visible') + .type(`{selectall}{del}${vpcLabel}`); + }, + + /** + * Sets the VPC create form's description field. + * + * @param vpcDescription - VPC description to set. + */ + setDescription: (vpcDescription: string) => { + cy.findByLabelText('Description', { exact: false }) + .should('be.visible') + .type(`{selectall}{del}${vpcDescription}`); + }, + + /** + * Sets the VPC create form's subnet label. + * + * When handling more than one subnet, an index can be provided to control + * which field is being modified. + * + * @param subnetLabel - Label to set. + * @param subnetIndex - Optional index of subnet for which to update label. + */ + setSubnetLabel: (subnetLabel: string, subnetIndex: number = 0) => { + cy.findByText('Subnet Label', { + selector: `label[for="subnet-label-${subnetIndex}"]`, + }) + .should('be.visible') + .click(); + + cy.focused().type(`{selectall}{del}${subnetLabel}`); + }, + + /** + * Sets the VPC create form's subnet IP address. + * + * When handling more than one subnet, an index can be provided to control + * which field is being modified. + * + * @param subnetIpRange - IP range to set. + * @param subnetIndex - Optional index of subnet for which to update IP range. + */ + setSubnetIpRange: (subnetIpRange: string, subnetIndex: number = 0) => { + cy.findByText('Subnet IP Address Range', { + selector: `label[for="subnet-ipv4-${subnetIndex}"]`, + }) + .should('be.visible') + .click(); + + cy.focused().type(`{selectall}{del}${subnetIpRange}`); + }, + + /** + * Submits the VPC create form. + */ + submit: () => { + ui.buttonGroup + .findButtonByTitle('Create VPC') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + }, +}; diff --git a/packages/manager/src/components/Paper.tsx b/packages/manager/src/components/Paper.tsx index c12f8ce9530..61bc8f129c3 100644 --- a/packages/manager/src/components/Paper.tsx +++ b/packages/manager/src/components/Paper.tsx @@ -30,6 +30,7 @@ export const Paper = (props: Props) => { {props.error && {props.error}} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx index e4e9c2da42d..9501b863392 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx @@ -15,6 +15,7 @@ import { renderMonthlyPriceToCorrectDecimalPlace } from 'src/utilities/pricing/d import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import type { CreateLinodeRequest } from '@linode/api-v4'; +import { extendType } from 'src/utilities/extendType'; export const Summary = () => { const theme = useTheme(); @@ -78,7 +79,7 @@ export const Summary = () => { { item: { details: `$${price?.monthly}/month`, - title: type?.label, + title: type && extendType(type).formattedLabel, }, show: Boolean(type), }, @@ -124,7 +125,7 @@ export const Summary = () => { const summaryItemsToShow = summaryItems.filter((item) => item.show); return ( - + Summary {label} {summaryItemsToShow.length === 0 ? ( From 6efdb47df78b9ec643eb7b07943c7aead92fa5c2 Mon Sep 17 00:00:00 2001 From: Banks Nussman Date: Thu, 23 May 2024 12:02:24 -0400 Subject: [PATCH 017/163] Cloud version v1.120.0, API v4 version v0.118.0, and Validation version v0.47.0 --- .../pr-10443-added-1715882636050.md | 5 -- .../pr-10462-changed-1715896838291.md | 5 -- packages/api-v4/CHANGELOG.md | 12 +++++ packages/api-v4/package.json | 2 +- .../pr-10392-tech-stories-1715207755092.md | 5 -- .../pr-10422-tech-stories-1714510098465.md | 5 -- .../pr-10443-added-1715882541919.md | 5 -- .../pr-10446-tests-1715197487403.md | 5 -- .../pr-10449-tests-1715196877025.md | 5 -- ...r-10454-upcoming-features-1715611024690.md | 5 -- ...r-10455-upcoming-features-1715611895814.md | 5 -- .../pr-10460-tech-stories-1715630809115.md | 5 -- .../pr-10462-tests-1716303824484.md | 5 -- ...r-10462-upcoming-features-1715896941491.md | 5 -- .../pr-10463-tech-stories-1715699566824.md | 5 -- .../pr-10464-tech-stories-1715711350251.md | 5 -- .../pr-10465-tests-1715702208954.md | 5 -- .../pr-10467-tests-1715779598575.md | 5 -- .../pr-10470-tests-1715719518601.md | 5 -- .../pr-10471-added-1715780084724.md | 5 -- .../pr-10471-tests-1715780339835.md | 5 -- .../pr-10472-tests-1715794017061.md | 5 -- .../pr-10473-tests-1715795923381.md | 5 -- .../pr-10474-added-1715961690900.md | 5 -- .../pr-10476-tests-1715869194947.md | 5 -- .../pr-10478-tests-1715876232371.md | 5 -- .../pr-10483-tech-stories-1715958313413.md | 5 -- .../pr-10485-tech-stories-1716214544667.md | 5 -- ...r-10486-upcoming-features-1716217159383.md | 5 -- .../pr-10489-removed-1716225501082.md | 5 -- .../pr-10490-fixed-1716227619276.md | 5 -- .../pr-10493-tests-1716312638686.md | 5 -- .../pr-10495-fixed-1716307990793.md | 5 -- .../pr-10501-tech-stories-1716324364842.md | 5 -- packages/manager/CHANGELOG.md | 54 +++++++++++++++++++ packages/manager/package.json | 2 +- .../pr-10462-changed-1715896899402.md | 5 -- .../pr-10471-added-1715780189054.md | 5 -- .../pr-10471-changed-1715780120037.md | 5 -- packages/validation/CHANGELOG.md | 13 +++++ packages/validation/package.json | 2 +- 41 files changed, 82 insertions(+), 178 deletions(-) delete mode 100644 packages/api-v4/.changeset/pr-10443-added-1715882636050.md delete mode 100644 packages/api-v4/.changeset/pr-10462-changed-1715896838291.md delete mode 100644 packages/manager/.changeset/pr-10392-tech-stories-1715207755092.md delete mode 100644 packages/manager/.changeset/pr-10422-tech-stories-1714510098465.md delete mode 100644 packages/manager/.changeset/pr-10443-added-1715882541919.md delete mode 100644 packages/manager/.changeset/pr-10446-tests-1715197487403.md delete mode 100644 packages/manager/.changeset/pr-10449-tests-1715196877025.md delete mode 100644 packages/manager/.changeset/pr-10454-upcoming-features-1715611024690.md delete mode 100644 packages/manager/.changeset/pr-10455-upcoming-features-1715611895814.md delete mode 100644 packages/manager/.changeset/pr-10460-tech-stories-1715630809115.md delete mode 100644 packages/manager/.changeset/pr-10462-tests-1716303824484.md delete mode 100644 packages/manager/.changeset/pr-10462-upcoming-features-1715896941491.md delete mode 100644 packages/manager/.changeset/pr-10463-tech-stories-1715699566824.md delete mode 100644 packages/manager/.changeset/pr-10464-tech-stories-1715711350251.md delete mode 100644 packages/manager/.changeset/pr-10465-tests-1715702208954.md delete mode 100644 packages/manager/.changeset/pr-10467-tests-1715779598575.md delete mode 100644 packages/manager/.changeset/pr-10470-tests-1715719518601.md delete mode 100644 packages/manager/.changeset/pr-10471-added-1715780084724.md delete mode 100644 packages/manager/.changeset/pr-10471-tests-1715780339835.md delete mode 100644 packages/manager/.changeset/pr-10472-tests-1715794017061.md delete mode 100644 packages/manager/.changeset/pr-10473-tests-1715795923381.md delete mode 100644 packages/manager/.changeset/pr-10474-added-1715961690900.md delete mode 100644 packages/manager/.changeset/pr-10476-tests-1715869194947.md delete mode 100644 packages/manager/.changeset/pr-10478-tests-1715876232371.md delete mode 100644 packages/manager/.changeset/pr-10483-tech-stories-1715958313413.md delete mode 100644 packages/manager/.changeset/pr-10485-tech-stories-1716214544667.md delete mode 100644 packages/manager/.changeset/pr-10486-upcoming-features-1716217159383.md delete mode 100644 packages/manager/.changeset/pr-10489-removed-1716225501082.md delete mode 100644 packages/manager/.changeset/pr-10490-fixed-1716227619276.md delete mode 100644 packages/manager/.changeset/pr-10493-tests-1716312638686.md delete mode 100644 packages/manager/.changeset/pr-10495-fixed-1716307990793.md delete mode 100644 packages/manager/.changeset/pr-10501-tech-stories-1716324364842.md delete mode 100644 packages/validation/.changeset/pr-10462-changed-1715896899402.md delete mode 100644 packages/validation/.changeset/pr-10471-added-1715780189054.md delete mode 100644 packages/validation/.changeset/pr-10471-changed-1715780120037.md diff --git a/packages/api-v4/.changeset/pr-10443-added-1715882636050.md b/packages/api-v4/.changeset/pr-10443-added-1715882636050.md deleted file mode 100644 index 4ae253140ec..00000000000 --- a/packages/api-v4/.changeset/pr-10443-added-1715882636050.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Added ---- - -New LKE events in `EventAction` type ([#10443](https://github.com/linode/manager/pull/10443)) diff --git a/packages/api-v4/.changeset/pr-10462-changed-1715896838291.md b/packages/api-v4/.changeset/pr-10462-changed-1715896838291.md deleted file mode 100644 index 2f5298f117d..00000000000 --- a/packages/api-v4/.changeset/pr-10462-changed-1715896838291.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Changed ---- - -Add Disk Encryption to AccountCapability type and region Capabilities type ([#10462](https://github.com/linode/manager/pull/10462)) diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 4060db60e8f..7e5688d793b 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,15 @@ +## [2024-05-28] - v0.118.0 + + +### Added: + +- New LKE events in `EventAction` type ([#10443](https://github.com/linode/manager/pull/10443)) + +### Changed: + +- Add Disk Encryption to AccountCapability type and region Capabilities type ([#10462](https://github.com/linode/manager/pull/10462)) + + ## [2024-05-13] - v0.117.0 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 96d71f158c9..e6bcbdc9552 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.117.0", + "version": "0.118.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/manager/.changeset/pr-10392-tech-stories-1715207755092.md b/packages/manager/.changeset/pr-10392-tech-stories-1715207755092.md deleted file mode 100644 index d5765fe5f99..00000000000 --- a/packages/manager/.changeset/pr-10392-tech-stories-1715207755092.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Add analytics form tracking for Linode Create flow (v1) ([#10392](https://github.com/linode/manager/pull/10392)) diff --git a/packages/manager/.changeset/pr-10422-tech-stories-1714510098465.md b/packages/manager/.changeset/pr-10422-tech-stories-1714510098465.md deleted file mode 100644 index dec693443fb..00000000000 --- a/packages/manager/.changeset/pr-10422-tech-stories-1714510098465.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Add script to generate internal test results payload ([#10422](https://github.com/linode/manager/pull/10422)) diff --git a/packages/manager/.changeset/pr-10443-added-1715882541919.md b/packages/manager/.changeset/pr-10443-added-1715882541919.md deleted file mode 100644 index b61255dad42..00000000000 --- a/packages/manager/.changeset/pr-10443-added-1715882541919.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Event message handling for new LKE event types ([#10443](https://github.com/linode/manager/pull/10443)) diff --git a/packages/manager/.changeset/pr-10446-tests-1715197487403.md b/packages/manager/.changeset/pr-10446-tests-1715197487403.md deleted file mode 100644 index c587975864e..00000000000 --- a/packages/manager/.changeset/pr-10446-tests-1715197487403.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add Placement Group populated landing page UI tests ([#10446](https://github.com/linode/manager/pull/10446)) diff --git a/packages/manager/.changeset/pr-10449-tests-1715196877025.md b/packages/manager/.changeset/pr-10449-tests-1715196877025.md deleted file mode 100644 index 0ee2d478858..00000000000 --- a/packages/manager/.changeset/pr-10449-tests-1715196877025.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add Placement Group Linode assignment UI tests ([#10449](https://github.com/linode/manager/pull/10449)) diff --git a/packages/manager/.changeset/pr-10454-upcoming-features-1715611024690.md b/packages/manager/.changeset/pr-10454-upcoming-features-1715611024690.md deleted file mode 100644 index 0e9637d3b7f..00000000000 --- a/packages/manager/.changeset/pr-10454-upcoming-features-1715611024690.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Linode Create Refactor - Scroll Errors Into View ([#10454](https://github.com/linode/manager/pull/10454)) diff --git a/packages/manager/.changeset/pr-10455-upcoming-features-1715611895814.md b/packages/manager/.changeset/pr-10455-upcoming-features-1715611895814.md deleted file mode 100644 index ae5404dcd5b..00000000000 --- a/packages/manager/.changeset/pr-10455-upcoming-features-1715611895814.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -PlacementGroups Select optimizations & cleanup ([#10455](https://github.com/linode/manager/pull/10455)) diff --git a/packages/manager/.changeset/pr-10460-tech-stories-1715630809115.md b/packages/manager/.changeset/pr-10460-tech-stories-1715630809115.md deleted file mode 100644 index f95e5abb78a..00000000000 --- a/packages/manager/.changeset/pr-10460-tech-stories-1715630809115.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Clean up NodeBalancer Firewall feature flag ([#10460](https://github.com/linode/manager/pull/10460)) diff --git a/packages/manager/.changeset/pr-10462-tests-1716303824484.md b/packages/manager/.changeset/pr-10462-tests-1716303824484.md deleted file mode 100644 index 8a4005759be..00000000000 --- a/packages/manager/.changeset/pr-10462-tests-1716303824484.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add Cypress test coverage for Disk Encryption in Linode Create flow ([#10462](https://github.com/linode/manager/pull/10462)) diff --git a/packages/manager/.changeset/pr-10462-upcoming-features-1715896941491.md b/packages/manager/.changeset/pr-10462-upcoming-features-1715896941491.md deleted file mode 100644 index 09bbf5b63a3..00000000000 --- a/packages/manager/.changeset/pr-10462-upcoming-features-1715896941491.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add Disk Encryption section to Linode Create flow ([#10462](https://github.com/linode/manager/pull/10462)) diff --git a/packages/manager/.changeset/pr-10463-tech-stories-1715699566824.md b/packages/manager/.changeset/pr-10463-tech-stories-1715699566824.md deleted file mode 100644 index 8a3d578749e..00000000000 --- a/packages/manager/.changeset/pr-10463-tech-stories-1715699566824.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Update Storybook to 8.1.0 ([#10463](https://github.com/linode/manager/pull/10463)) diff --git a/packages/manager/.changeset/pr-10464-tech-stories-1715711350251.md b/packages/manager/.changeset/pr-10464-tech-stories-1715711350251.md deleted file mode 100644 index 3ee90fd3f51..00000000000 --- a/packages/manager/.changeset/pr-10464-tech-stories-1715711350251.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Upgrade country-region-data to 3.0.0 ([#10464](https://github.com/linode/manager/pull/10464)) diff --git a/packages/manager/.changeset/pr-10465-tests-1715702208954.md b/packages/manager/.changeset/pr-10465-tests-1715702208954.md deleted file mode 100644 index 30c1318e0c7..00000000000 --- a/packages/manager/.changeset/pr-10465-tests-1715702208954.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Clean up support ticket test intercepts ([#10465](https://github.com/linode/manager/pull/10465)) diff --git a/packages/manager/.changeset/pr-10467-tests-1715779598575.md b/packages/manager/.changeset/pr-10467-tests-1715779598575.md deleted file mode 100644 index 05b57b404a9..00000000000 --- a/packages/manager/.changeset/pr-10467-tests-1715779598575.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Clean up cy.intercept calls in nodebalancer ([#10467](https://github.com/linode/manager/pull/10467)) diff --git a/packages/manager/.changeset/pr-10470-tests-1715719518601.md b/packages/manager/.changeset/pr-10470-tests-1715719518601.md deleted file mode 100644 index 6f1b819910e..00000000000 --- a/packages/manager/.changeset/pr-10470-tests-1715719518601.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Fix failing StackScript test following deprecation of Fedora 38 Image ([#10470](https://github.com/linode/manager/pull/10470)) diff --git a/packages/manager/.changeset/pr-10471-added-1715780084724.md b/packages/manager/.changeset/pr-10471-added-1715780084724.md deleted file mode 100644 index d6583cf25b2..00000000000 --- a/packages/manager/.changeset/pr-10471-added-1715780084724.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Tags to Image create capture tab ([#10471](https://github.com/linode/manager/pull/10471)) diff --git a/packages/manager/.changeset/pr-10471-tests-1715780339835.md b/packages/manager/.changeset/pr-10471-tests-1715780339835.md deleted file mode 100644 index 3a47837eb67..00000000000 --- a/packages/manager/.changeset/pr-10471-tests-1715780339835.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Clean up and improves image creation Cypress tests ([#10471](https://github.com/linode/manager/pull/10471)) diff --git a/packages/manager/.changeset/pr-10472-tests-1715794017061.md b/packages/manager/.changeset/pr-10472-tests-1715794017061.md deleted file mode 100644 index fb19a708b31..00000000000 --- a/packages/manager/.changeset/pr-10472-tests-1715794017061.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Clean up cy.intercept calls in notification and events ([#10472](https://github.com/linode/manager/pull/10472)) diff --git a/packages/manager/.changeset/pr-10473-tests-1715795923381.md b/packages/manager/.changeset/pr-10473-tests-1715795923381.md deleted file mode 100644 index e803729ce57..00000000000 --- a/packages/manager/.changeset/pr-10473-tests-1715795923381.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add Integration Test for Linode Create with Placement Group ([#10473](https://github.com/linode/manager/pull/10473)) diff --git a/packages/manager/.changeset/pr-10474-added-1715961690900.md b/packages/manager/.changeset/pr-10474-added-1715961690900.md deleted file mode 100644 index 6ae952321ab..00000000000 --- a/packages/manager/.changeset/pr-10474-added-1715961690900.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Add options for default policies when creating a Firewall ([#10474](https://github.com/linode/manager/pull/10474)) diff --git a/packages/manager/.changeset/pr-10476-tests-1715869194947.md b/packages/manager/.changeset/pr-10476-tests-1715869194947.md deleted file mode 100644 index e11c1b69692..00000000000 --- a/packages/manager/.changeset/pr-10476-tests-1715869194947.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Clean up cy.intercept calls in resize-linode ([#10476](https://github.com/linode/manager/pull/10476)) diff --git a/packages/manager/.changeset/pr-10478-tests-1715876232371.md b/packages/manager/.changeset/pr-10478-tests-1715876232371.md deleted file mode 100644 index cd904644c91..00000000000 --- a/packages/manager/.changeset/pr-10478-tests-1715876232371.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Clean up cy.intercept calls in smoke-delete-linode ([#10478](https://github.com/linode/manager/pull/10478)) diff --git a/packages/manager/.changeset/pr-10483-tech-stories-1715958313413.md b/packages/manager/.changeset/pr-10483-tech-stories-1715958313413.md deleted file mode 100644 index 5b072a0bf02..00000000000 --- a/packages/manager/.changeset/pr-10483-tech-stories-1715958313413.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Retire recharts feature flag ([#10483](https://github.com/linode/manager/pull/10483)) diff --git a/packages/manager/.changeset/pr-10485-tech-stories-1716214544667.md b/packages/manager/.changeset/pr-10485-tech-stories-1716214544667.md deleted file mode 100644 index 290bea04284..00000000000 --- a/packages/manager/.changeset/pr-10485-tech-stories-1716214544667.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Remove aria-label from TableRow ([#10485](https://github.com/linode/manager/pull/10485)) diff --git a/packages/manager/.changeset/pr-10486-upcoming-features-1716217159383.md b/packages/manager/.changeset/pr-10486-upcoming-features-1716217159383.md deleted file mode 100644 index 4f595dc7d54..00000000000 --- a/packages/manager/.changeset/pr-10486-upcoming-features-1716217159383.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Reset errors in PlacementGroupDeleteModal ([#10486](https://github.com/linode/manager/pull/10486)) diff --git a/packages/manager/.changeset/pr-10489-removed-1716225501082.md b/packages/manager/.changeset/pr-10489-removed-1716225501082.md deleted file mode 100644 index 1564d68e00b..00000000000 --- a/packages/manager/.changeset/pr-10489-removed-1716225501082.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Removed ---- - -`parentChildAccountAccess` feature flag ([#10489](https://github.com/linode/manager/pull/10489)) diff --git a/packages/manager/.changeset/pr-10490-fixed-1716227619276.md b/packages/manager/.changeset/pr-10490-fixed-1716227619276.md deleted file mode 100644 index 4e2fdf04d94..00000000000 --- a/packages/manager/.changeset/pr-10490-fixed-1716227619276.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Duplicate speedtest helper text in Create Cluster form ([#10490](https://github.com/linode/manager/pull/10490)) diff --git a/packages/manager/.changeset/pr-10493-tests-1716312638686.md b/packages/manager/.changeset/pr-10493-tests-1716312638686.md deleted file mode 100644 index e0687a7fdbf..00000000000 --- a/packages/manager/.changeset/pr-10493-tests-1716312638686.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add cypress assertion and test for placement group deletion error handling ([#10493](https://github.com/linode/manager/pull/10493)) diff --git a/packages/manager/.changeset/pr-10495-fixed-1716307990793.md b/packages/manager/.changeset/pr-10495-fixed-1716307990793.md deleted file mode 100644 index 6c15b21bcfa..00000000000 --- a/packages/manager/.changeset/pr-10495-fixed-1716307990793.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -`RegionSelect` unexpected keyboard behavior ([#10495](https://github.com/linode/manager/pull/10495)) diff --git a/packages/manager/.changeset/pr-10501-tech-stories-1716324364842.md b/packages/manager/.changeset/pr-10501-tech-stories-1716324364842.md deleted file mode 100644 index e97bd3728cf..00000000000 --- a/packages/manager/.changeset/pr-10501-tech-stories-1716324364842.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Make all tooltips Interactive and prevent `disableInteractive` for future usage ([#10501](https://github.com/linode/manager/pull/10501)) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 7356fbbda3a..8ab21bda0ad 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,60 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2024-05-28] - v1.120.0 + + +### Added: + +- Event message handling for new LKE event types ([#10443](https://github.com/linode/manager/pull/10443)) +- Tags to Image Create capture tab ([#10471](https://github.com/linode/manager/pull/10471)) +- Options for default policies when creating a Firewall ([#10474](https://github.com/linode/manager/pull/10474)) + +### Changed: + +- Make all tooltips interactive and prevent `disableInteractive` for future usage ([#10501](https://github.com/linode/manager/pull/10501)) + +### Fixed: + +- Duplicate speedtest helper text in Create Cluster form ([#10490](https://github.com/linode/manager/pull/10490)) +- `RegionSelect` unexpected keyboard behavior ([#10495](https://github.com/linode/manager/pull/10495)) + +### Removed: + +- `parentChildAccountAccess` feature flag ([#10489](https://github.com/linode/manager/pull/10489)) +- `firewallNodebalancer` feature flag ([#10460](https://github.com/linode/manager/pull/10460)) +- `recharts` feature flag ([#10483](https://github.com/linode/manager/pull/10483)) + +### Tech Stories: + +- Add script to generate internal test results payload ([#10422](https://github.com/linode/manager/pull/10422)) +- Update Storybook to 8.1.0 ([#10463](https://github.com/linode/manager/pull/10463)) +- Upgrade country-region-data to 3.0.0 ([#10464](https://github.com/linode/manager/pull/10464)) +- Remove aria-label from TableRow ([#10485](https://github.com/linode/manager/pull/10485)) + +### Tests: + +- Add Placement Group populated landing page UI tests ([#10446](https://github.com/linode/manager/pull/10446)) +- Add Placement Group Linode assignment UI tests ([#10449](https://github.com/linode/manager/pull/10449)) +- Add Cypress test coverage for Disk Encryption in Linode Create flow ([#10462](https://github.com/linode/manager/pull/10462)) +- Clean up support ticket test intercepts ([#10465](https://github.com/linode/manager/pull/10465)) +- Clean up cy.intercept calls in nodebalancer test ([#10467](https://github.com/linode/manager/pull/10467)) +- Fix failing StackScript test following deprecation of Fedora 38 Image ([#10470](https://github.com/linode/manager/pull/10470)) +- Clean up and improves image creation Cypress tests ([#10471](https://github.com/linode/manager/pull/10471)) +- Clean up cy.intercept calls in notification and events ([#10472](https://github.com/linode/manager/pull/10472)) +- Add integration test for Linode Create with Placement Group ([#10473](https://github.com/linode/manager/pull/10473)) +- Clean up cy.intercept calls in resize-linode test ([#10476](https://github.com/linode/manager/pull/10476)) +- Clean up cy.intercept calls in smoke-delete-linode test ([#10478](https://github.com/linode/manager/pull/10478)) +- Add cypress assertion and test for placement group deletion error handling ([#10493](https://github.com/linode/manager/pull/10493)) + +### Upcoming Features: + +- Linode Create Refactor - Scroll Errors Into View ([#10454](https://github.com/linode/manager/pull/10454)) +- Optimize and clean up PlacementGroups Select ([#10455](https://github.com/linode/manager/pull/10455)) +- Add Disk Encryption section to Linode Create flow ([#10462](https://github.com/linode/manager/pull/10462)) +- Reset errors in PlacementGroupDeleteModal ([#10486](https://github.com/linode/manager/pull/10486)) + + ## [2024-05-13] - v1.119.0 diff --git a/packages/manager/package.json b/packages/manager/package.json index 1b3bd43570e..8d130cfd0ba 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.119.0", + "version": "1.120.0", "private": true, "type": "module", "bugs": { diff --git a/packages/validation/.changeset/pr-10462-changed-1715896899402.md b/packages/validation/.changeset/pr-10462-changed-1715896899402.md deleted file mode 100644 index d925f5b751b..00000000000 --- a/packages/validation/.changeset/pr-10462-changed-1715896899402.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/validation": Changed ---- - -Adjust DiskEncryptionSchema so it is not an object ([#10462](https://github.com/linode/manager/pull/10462)) diff --git a/packages/validation/.changeset/pr-10471-added-1715780189054.md b/packages/validation/.changeset/pr-10471-added-1715780189054.md deleted file mode 100644 index baab53173bd..00000000000 --- a/packages/validation/.changeset/pr-10471-added-1715780189054.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/validation": Added ---- - -`tags` to `createImageSchema` ([#10471](https://github.com/linode/manager/pull/10471)) diff --git a/packages/validation/.changeset/pr-10471-changed-1715780120037.md b/packages/validation/.changeset/pr-10471-changed-1715780120037.md deleted file mode 100644 index d107a1db4be..00000000000 --- a/packages/validation/.changeset/pr-10471-changed-1715780120037.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/validation": Changed ---- - -Improved Image `label` validation ([#10471](https://github.com/linode/manager/pull/10471)) diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index ec68c31ee97..95a729167c0 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,3 +1,16 @@ +## [2024-05-28] - v0.47.0 + + +### Added: + +- `tags` to `createImageSchema` ([#10471](https://github.com/linode/manager/pull/10471)) + +### Changed: + +- Adjust DiskEncryptionSchema so it is not an object ([#10462](https://github.com/linode/manager/pull/10462)) +- Improve Image `label` validation ([#10471](https://github.com/linode/manager/pull/10471)) + + ## [2024-05-13] - v0.46.0 diff --git a/packages/validation/package.json b/packages/validation/package.json index 49e4a98a2f0..1b89d5b4379 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.46.0", + "version": "0.47.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", From f99456f371fb75da8e8d5e47b60301da69570751 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Thu, 23 May 2024 09:06:52 -0700 Subject: [PATCH 018/163] refactor: [M3-7382] - Use `object-storage/types` endpoint for pricing data (#10468) * Set up endpoint, query, and save work * Add loading and error state for Create Bucket button * Add dynamic pricing to modal; add loading/error state to drawer * Add dynamicPricing test coverage for interval * Add decimal precision to util args * Add WIP tests * Add mock endpoint to MSW * Clean up loading * Update OveragePricing test coverage * Update dynamicPricing utils test coverage * Clean up to disable types query if only creating an access key * Add test coverage to modal; clean up * Clean up old constants in test spec * Remove trailing zero for base price storage overage * Add changesets * Clean up from self review * Address feedback: @bnussman-akamai --- .../pr-10468-added-1716219170108.md | 5 ++ packages/api-v4/src/object-storage/index.ts | 2 + packages/api-v4/src/object-storage/prices.ts | 16 ++++ .../pr-10468-changed-1716219242980.md | 5 ++ .../enable-object-storage.spec.ts | 10 --- packages/manager/src/factories/types.ts | 46 +++++++++++ .../features/Account/EnableObjectStorage.tsx | 2 +- .../BucketLanding/CreateBucketDrawer.tsx | 20 ++++- .../BucketLanding/OveragePricing.test.tsx | 81 +++++++++++++++++-- .../BucketLanding/OveragePricing.tsx | 37 +++++++-- .../EnableObjectStorageModal.test.tsx | 50 +++++++++++- .../EnableObjectStorageModal.tsx | 33 +++++++- packages/manager/src/mocks/serverHandlers.ts | 9 +++ packages/manager/src/queries/objectStorage.ts | 21 ++++- .../utilities/pricing/dynamicPricing.test.ts | 22 +++++ .../src/utilities/pricing/dynamicPricing.ts | 19 ++++- 16 files changed, 341 insertions(+), 37 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10468-added-1716219170108.md create mode 100644 packages/api-v4/src/object-storage/prices.ts create mode 100644 packages/manager/.changeset/pr-10468-changed-1716219242980.md diff --git a/packages/api-v4/.changeset/pr-10468-added-1716219170108.md b/packages/api-v4/.changeset/pr-10468-added-1716219170108.md new file mode 100644 index 00000000000..2a5eab0136d --- /dev/null +++ b/packages/api-v4/.changeset/pr-10468-added-1716219170108.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +New endpoint for `object-storage/types` ([#10468](https://github.com/linode/manager/pull/10468)) diff --git a/packages/api-v4/src/object-storage/index.ts b/packages/api-v4/src/object-storage/index.ts index f4a9bdf8d18..e2985222d3f 100644 --- a/packages/api-v4/src/object-storage/index.ts +++ b/packages/api-v4/src/object-storage/index.ts @@ -9,3 +9,5 @@ export * from './objects'; export * from './objectStorageKeys'; export * from './types'; + +export * from './prices'; diff --git a/packages/api-v4/src/object-storage/prices.ts b/packages/api-v4/src/object-storage/prices.ts new file mode 100644 index 00000000000..2907a6a110c --- /dev/null +++ b/packages/api-v4/src/object-storage/prices.ts @@ -0,0 +1,16 @@ +import { Params, PriceType, ResourcePage } from 'src/types'; +import { API_ROOT } from '../constants'; +import Request, { setMethod, setParams, setURL } from '../request'; + +/** + * getObjectStorageTypes + * + * Return a paginated list of available Object Storage types; used for pricing. + * This endpoint does not require authentication. + */ +export const getObjectStorageTypes = (params?: Params) => + Request>( + setURL(`${API_ROOT}/object-storage/types`), + setMethod('GET'), + setParams(params) + ); diff --git a/packages/manager/.changeset/pr-10468-changed-1716219242980.md b/packages/manager/.changeset/pr-10468-changed-1716219242980.md new file mode 100644 index 00000000000..729048a7d33 --- /dev/null +++ b/packages/manager/.changeset/pr-10468-changed-1716219242980.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Use dynamic pricing with `object-storage/types` endpoint ([#10468](https://github.com/linode/manager/pull/10468)) diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index 85735a3cf48..76459bd7b5b 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -36,16 +36,6 @@ import { makeFeatureFlagData } from 'support/util/feature-flags'; // Various messages, notes, and warnings that may be shown when enabling Object Storage // under different circumstances. const objNotes = { - // When enabling OBJ using a region with a regular pricing structure, when OBJ DC-specific pricing is disabled. - regularPricing: /Linode Object Storage costs a flat rate of \$5\/month, and includes 250 GB of storage and 1 TB of outbound data transfer. Beyond that, it.*s \$0.02 per GB per month./, - - // When enabling OBJ using a region with special pricing during the free beta period (OBJ DC-specific pricing is disabled). - dcSpecificBetaPricing: /Object Storage for .* is currently in beta\. During the beta period, Object Storage in these regions is free\. After the beta period, customers will be notified before charges for this service begin./, - - // When enabling OBJ without having selected a region, when OBJ DC-specific pricing is disabled. - dcPricingGenericExplanation: - 'Pricing for monthly rate and overage costs will depend on the data center you select for deployment.', - // When enabling OBJ, in both the Access Key flow and Create Bucket flow, when OBJ DC-specific pricing is enabled. objDCPricing: 'Object Storage costs a flat rate of $5/month, and includes 250 GB of storage. When you enable Object Storage, 1 TB of outbound data transfer will be added to your global network transfer pool.', diff --git a/packages/manager/src/factories/types.ts b/packages/manager/src/factories/types.ts index 830f7efdbcd..5229d085b50 100644 --- a/packages/manager/src/factories/types.ts +++ b/packages/manager/src/factories/types.ts @@ -172,3 +172,49 @@ export const volumeTypeFactory = Factory.Sync.makeFactory({ ], transfer: 0, }); + +export const objectStorageTypeFactory = Factory.Sync.makeFactory({ + id: 'objectstorage', + label: 'Object Storage', + price: { + hourly: 0.0075, + monthly: 5.0, + }, + region_prices: [ + { + hourly: 0.0075, + id: 'id-cgk', + monthly: 5.0, + }, + { + hourly: 0.0075, + id: 'br-gru', + monthly: 5.0, + }, + ], + transfer: 1000, +}); + +export const objectStorageOverageTypeFactory = Factory.Sync.makeFactory( + { + id: 'objectstorage-overage', + label: 'Object Storage Overage', + price: { + hourly: 0.02, + monthly: null, + }, + region_prices: [ + { + hourly: 0.024, + id: 'id-cgk', + monthly: null, + }, + { + hourly: 0.028, + id: 'br-gru', + monthly: null, + }, + ], + transfer: 0, + } +); diff --git a/packages/manager/src/features/Account/EnableObjectStorage.tsx b/packages/manager/src/features/Account/EnableObjectStorage.tsx index 51e74662cf0..f6562865e36 100644 --- a/packages/manager/src/features/Account/EnableObjectStorage.tsx +++ b/packages/manager/src/features/Account/EnableObjectStorage.tsx @@ -2,8 +2,8 @@ import { AccountSettings } from '@linode/api-v4/lib/account'; import { cancelObjectStorage } from '@linode/api-v4/lib/object-storage'; import { APIError } from '@linode/api-v4/lib/types'; import Grid from '@mui/material/Unstable_Grid2'; -import * as React from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import * as React from 'react'; import { Accordion } from 'src/components/Accordion'; import { Button } from 'src/components/Button/Button'; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx index 3abd6957b22..f470c3d3d0f 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx @@ -19,6 +19,7 @@ import { useCreateBucketMutation, useObjectStorageBuckets, useObjectStorageClusters, + useObjectStorageTypesQuery, } from 'src/queries/objectStorage'; import { useProfile } from 'src/queries/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -26,6 +27,7 @@ import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { sendCreateBucketEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getErrorMap } from 'src/utilities/errorUtils'; import { getGDPRDetails } from 'src/utilities/formatRegion'; +import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; import { EnableObjectStorageModal } from '../EnableObjectStorageModal'; import ClusterSelect from './ClusterSelect'; @@ -74,6 +76,14 @@ export const CreateBucketDrawer = (props: Props) => { : undefined, }); + const { + data: types, + isError: isErrorTypes, + isLoading: isLoadingTypes, + } = useObjectStorageTypesQuery(isOpen); + + const isInvalidPrice = !types || isErrorTypes; + const { error, isLoading, @@ -199,9 +209,15 @@ export const CreateBucketDrawer = (props: Props) => { 'data-testid': 'create-bucket-button', disabled: !formik.values.cluster || - (showGDPRCheckbox && !hasSignedAgreement), + (showGDPRCheckbox && !hasSignedAgreement) || + isErrorTypes, label: 'Create Bucket', - loading: isLoading, + loading: + isLoading || Boolean(clusterRegion?.[0]?.id && isLoadingTypes), + tooltipText: + !isLoadingTypes && isInvalidPrice + ? PRICES_RELOAD_ERROR_NOTICE_TEXT + : '', type: 'submit', }} secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx index efc2669ea5a..43330dcaa49 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx @@ -1,7 +1,14 @@ import { fireEvent } from '@testing-library/react'; import React from 'react'; -import { OBJ_STORAGE_PRICE } from 'src/utilities/pricing/constants'; +import { + objectStorageOverageTypeFactory, + objectStorageTypeFactory, +} from 'src/factories'; +import { + OBJ_STORAGE_PRICE, + UNKNOWN_PRICE, +} from 'src/utilities/pricing/constants'; import { objectStoragePriceIncreaseMap } from 'src/utilities/pricing/dynamicPricing'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -11,12 +18,37 @@ import { OveragePricing, } from './OveragePricing'; -describe('OveragePricing', () => { +const mockObjectStorageTypes = [ + objectStorageTypeFactory.build(), + objectStorageOverageTypeFactory.build(), +]; + +const queryMocks = vi.hoisted(() => ({ + useObjectStorageTypesQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/objectStorage', async () => { + const actual = await vi.importActual('src/queries/objectStorage'); + return { + ...actual, + useObjectStorageTypesQuery: queryMocks.useObjectStorageTypesQuery, + }; +}); + +describe('OveragePricing', async () => { + beforeAll(() => { + queryMocks.useObjectStorageTypesQuery.mockReturnValue({ + data: mockObjectStorageTypes, + }); + }); + it('Renders base overage pricing for a region without price increases', () => { const { getByText } = renderWithTheme( ); - getByText(`$${OBJ_STORAGE_PRICE.storage_overage} per GB`, { exact: false }); + getByText(`$${mockObjectStorageTypes[1].price.hourly?.toFixed(2)} per GB`, { + exact: false, + }); getByText(`$${OBJ_STORAGE_PRICE.transfer_overage} per GB`, { exact: false, }); @@ -24,10 +56,9 @@ describe('OveragePricing', () => { it('Renders DC-specific overage pricing for a region with price increases', () => { const { getByText } = renderWithTheme(); - getByText( - `$${objectStoragePriceIncreaseMap['br-gru'].storage_overage} per GB`, - { exact: false } - ); + getByText(`$${mockObjectStorageTypes[1].region_prices[1].hourly} per GB`, { + exact: false, + }); getByText( `$${objectStoragePriceIncreaseMap['br-gru'].transfer_overage} per GB`, { exact: false } @@ -59,4 +90,40 @@ describe('OveragePricing', () => { expect(tooltip).toBeInTheDocument(); expect(getByText(GLOBAL_TRANSFER_POOL_TOOLTIP_TEXT)).toBeVisible(); }); + + it('Renders a loading state while prices are loading', () => { + queryMocks.useObjectStorageTypesQuery.mockReturnValue({ + isLoading: true, + }); + + const { getByRole } = renderWithTheme( + + ); + + expect(getByRole('progressbar')).toBeVisible(); + }); + + it('Renders placeholder unknown pricing when there is an error', () => { + queryMocks.useObjectStorageTypesQuery.mockReturnValue({ + isError: true, + }); + + const { getAllByText } = renderWithTheme( + + ); + + expect(getAllByText(`$${UNKNOWN_PRICE} per GB`)).toHaveLength(1); + }); + + it('Renders placeholder unknown pricing when prices are undefined', () => { + queryMocks.useObjectStorageTypesQuery.mockReturnValue({ + data: undefined, + }); + + const { getAllByText } = renderWithTheme( + + ); + + expect(getAllByText(`$${UNKNOWN_PRICE} per GB`)).toHaveLength(1); + }); }); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx index 25279f3efc9..727c20d5cdc 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx @@ -2,10 +2,18 @@ import { Region } from '@linode/api-v4'; import { styled } from '@mui/material/styles'; import React from 'react'; +import { CircularProgress } from 'src/components/CircularProgress'; import { TextTooltip } from 'src/components/TextTooltip'; import { Typography } from 'src/components/Typography'; -import { OBJ_STORAGE_PRICE } from 'src/utilities/pricing/constants'; -import { objectStoragePriceIncreaseMap } from 'src/utilities/pricing/dynamicPricing'; +import { useObjectStorageTypesQuery } from 'src/queries/objectStorage'; +import { + OBJ_STORAGE_PRICE, + UNKNOWN_PRICE, +} from 'src/utilities/pricing/constants'; +import { + getDCSpecificPriceByType, + objectStoragePriceIncreaseMap, +} from 'src/utilities/pricing/dynamicPricing'; interface Props { regionId: Region['id']; @@ -18,23 +26,40 @@ export const GLOBAL_TRANSFER_POOL_TOOLTIP_TEXT = export const OveragePricing = (props: Props) => { const { regionId } = props; + + const { data: types, isError, isLoading } = useObjectStorageTypesQuery(); + + const overageType = types?.find( + (type) => type.id === 'objectstorage-overage' + ); + + const storageOveragePrice = getDCSpecificPriceByType({ + decimalPrecision: 3, + interval: 'hourly', + regionId, + type: overageType, + }); + const isDcSpecificPricingRegion = objectStoragePriceIncreaseMap.hasOwnProperty( regionId ); - return ( + return isLoading ? ( + + ) : ( <> For this region, additional storage costs{' '} $ - {isDcSpecificPricingRegion - ? objectStoragePriceIncreaseMap[regionId].storage_overage - : OBJ_STORAGE_PRICE.storage_overage}{' '} + {storageOveragePrice && !isError + ? parseFloat(storageOveragePrice) + : UNKNOWN_PRICE}{' '} per GB . + Outbound transfer will cost{' '} diff --git a/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.test.tsx b/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.test.tsx index e36ba887af3..834cd38f4ec 100644 --- a/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.test.tsx +++ b/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.test.tsx @@ -1,7 +1,11 @@ import { fireEvent, render } from '@testing-library/react'; import * as React from 'react'; -import { OBJ_STORAGE_PRICE } from 'src/utilities/pricing/constants'; +import { + objectStorageOverageTypeFactory, + objectStorageTypeFactory, +} from 'src/factories'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { wrapWithTheme } from 'src/utilities/testHelpers'; import { @@ -23,7 +27,29 @@ const props: EnableObjectStorageProps = { open: true, }; +const queryMocks = vi.hoisted(() => ({ + useObjectStorageTypesQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/objectStorage', async () => { + const actual = await vi.importActual('src/queries/objectStorage'); + return { + ...actual, + useObjectStorageTypesQuery: queryMocks.useObjectStorageTypesQuery, + }; +}); + describe('EnableObjectStorageModal', () => { + beforeAll(() => { + const mockObjectStorageTypes = [ + objectStorageTypeFactory.build(), + objectStorageOverageTypeFactory.build(), + ]; + queryMocks.useObjectStorageTypesQuery.mockReturnValue({ + data: mockObjectStorageTypes, + }); + }); + it('includes a header', () => { const { getAllByText } = render( wrapWithTheme() @@ -37,7 +63,7 @@ describe('EnableObjectStorageModal', () => { ) ); - getByText(`$${OBJ_STORAGE_PRICE.monthly}/month`, { exact: false }); + getByText(`$5/month`, { exact: false }); getByText(OBJ_STORAGE_STORAGE_AMT, { exact: false }); getByText(OBJ_STORAGE_NETWORK_TRANSFER_AMT, { exact: false }); }); @@ -51,7 +77,7 @@ describe('EnableObjectStorageModal', () => { /> ) ); - getByText(`$${OBJ_STORAGE_PRICE.monthly}/month`, { exact: false }); + getByText(`$5/month`, { exact: false }); getByText(OBJ_STORAGE_STORAGE_AMT, { exact: false }); getByText(OBJ_STORAGE_NETWORK_TRANSFER_AMT, { exact: false }); }); @@ -60,11 +86,27 @@ describe('EnableObjectStorageModal', () => { const { getByText } = render( wrapWithTheme() ); - getByText(`$${OBJ_STORAGE_PRICE.monthly}/month`, { exact: false }); + getByText(`$5/month`, { exact: false }); getByText(OBJ_STORAGE_STORAGE_AMT, { exact: false }); getByText(OBJ_STORAGE_NETWORK_TRANSFER_AMT, { exact: false }); }); + it('displays placeholder unknown pricing and disables the primary action button if pricing is not available', () => { + queryMocks.useObjectStorageTypesQuery.mockReturnValue({ + data: undefined, + isError: true, + }); + + const { getByTestId, getByText } = render( + wrapWithTheme() + ); + + const primaryActionButton = getByTestId('enable-obj'); + + expect(getByText(`${UNKNOWN_PRICE}/month`, { exact: false })).toBeVisible(); + expect(primaryActionButton).toBeDisabled(); + }); + it('includes a link to linode.com/pricing', () => { const { getByText } = render( wrapWithTheme() diff --git a/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.tsx b/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.tsx index 8e990d54e31..fbc6d26cbd4 100644 --- a/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.tsx +++ b/packages/manager/src/features/ObjectStorage/EnableObjectStorageModal.tsx @@ -7,7 +7,12 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; -import { OBJ_STORAGE_PRICE } from 'src/utilities/pricing/constants'; +import { useObjectStorageTypesQuery } from 'src/queries/objectStorage'; +import { + PRICES_RELOAD_ERROR_NOTICE_TEXT, + UNKNOWN_PRICE, +} from 'src/utilities/pricing/constants'; +import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; export const OBJ_STORAGE_STORAGE_AMT = '250 GB'; export const OBJ_STORAGE_NETWORK_TRANSFER_AMT = '1 TB'; @@ -22,17 +27,41 @@ export const EnableObjectStorageModal = React.memo( (props: EnableObjectStorageProps) => { const { handleSubmit, onClose, open, regionId } = props; + const { data: types, isError, isLoading } = useObjectStorageTypesQuery( + Boolean(regionId) + ); + + const isInvalidPrice = Boolean(regionId) && (!types || isError); + + const objectStorageType = types?.find( + (type) => type.id === 'objectstorage' + ); + + const price = regionId + ? getDCSpecificPriceByType({ + decimalPrecision: 0, + regionId, + type: objectStorageType, + }) + : objectStorageType?.price.monthly; + return ( ( { onClose(); handleSubmit(); }, + tooltipText: + !isLoading && isInvalidPrice + ? PRICES_RELOAD_ERROR_NOTICE_TEXT + : '', }} secondaryButtonProps={{ 'data-testid': 'cancel', @@ -51,7 +80,7 @@ export const EnableObjectStorageModal = React.memo( Object Storage costs a flat rate of{' '} - ${OBJ_STORAGE_PRICE.monthly}/month, and includes{' '} + ${price ?? UNKNOWN_PRICE}/month, and includes{' '} {OBJ_STORAGE_STORAGE_AMT} of storage. When you enable Object Storage,{' '} {OBJ_STORAGE_NETWORK_TRANSFER_AMT} of outbound data transfer will be added to your global network transfer pool. diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index e1c89236f27..8cc5bb94aa7 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -76,6 +76,8 @@ import { objectStorageBucketFactory, objectStorageClusterFactory, objectStorageKeyFactory, + objectStorageTypeFactory, + objectStorageOverageTypeFactory, paymentFactory, paymentMethodFactory, placementGroupFactory, @@ -924,6 +926,13 @@ export const handlers = [ ]; return HttpResponse.json(makeResourcePage(configs)); }), + http.get('*/v4/object-storage/types', () => { + const objectStorageTypes = [ + objectStorageTypeFactory.build(), + objectStorageOverageTypeFactory.build(), + ]; + return HttpResponse.json(makeResourcePage(objectStorageTypes)); + }), http.get('*object-storage/buckets/*/*/access', async () => { await sleep(2000); return HttpResponse.json({ diff --git a/packages/manager/src/queries/objectStorage.ts b/packages/manager/src/queries/objectStorage.ts index 052a81d7989..44de89ed1a9 100644 --- a/packages/manager/src/queries/objectStorage.ts +++ b/packages/manager/src/queries/objectStorage.ts @@ -20,11 +20,17 @@ import { getClusters, getObjectList, getObjectStorageKeys, + getObjectStorageTypes, getObjectURL, getSSLCert, uploadSSLCert, } from '@linode/api-v4'; -import { APIError, Params, ResourcePage } from '@linode/api-v4/lib/types'; +import { + APIError, + Params, + PriceType, + ResourcePage, +} from '@linode/api-v4/lib/types'; import { QueryClient, useInfiniteQuery, @@ -406,3 +412,16 @@ export const useBucketSSLDeleteMutation = (cluster: string, bucket: string) => { }, }); }; + +const getAllObjectStorageTypes = () => + getAll((params) => getObjectStorageTypes(params))().then( + (data) => data.data + ); + +export const useObjectStorageTypesQuery = (enabled = true) => + useQuery({ + queryFn: getAllObjectStorageTypes, + queryKey: [queryKey, 'types'], + ...queryPresets.oneTimeFetch, + enabled, + }); diff --git a/packages/manager/src/utilities/pricing/dynamicPricing.test.ts b/packages/manager/src/utilities/pricing/dynamicPricing.test.ts index 4381a738abc..56c73482029 100644 --- a/packages/manager/src/utilities/pricing/dynamicPricing.test.ts +++ b/packages/manager/src/utilities/pricing/dynamicPricing.test.ts @@ -75,6 +75,28 @@ describe('getDCSpecificPricingByType', () => { ).toBe('14.00'); }); + it('calculates dynamic pricing for a region without an increase on an hourly interval to the specified decimal', () => { + expect( + getDCSpecificPriceByType({ + decimalPrecision: 3, + interval: 'hourly', + regionId: 'us-east', + type: mockNodeBalancerType, + }) + ).toBe('0.015'); + }); + + it('calculates dynamic pricing for a region with an increase on an hourly interval to the specified decimal', () => { + expect( + getDCSpecificPriceByType({ + decimalPrecision: 3, + interval: 'hourly', + regionId: 'id-cgk', + type: mockNodeBalancerType, + }) + ).toBe('0.018'); + }); + it('calculates dynamic pricing for a volume based on size', () => { expect( getDCSpecificPriceByType({ diff --git a/packages/manager/src/utilities/pricing/dynamicPricing.ts b/packages/manager/src/utilities/pricing/dynamicPricing.ts index 90598a37e6f..b190b0c5d64 100644 --- a/packages/manager/src/utilities/pricing/dynamicPricing.ts +++ b/packages/manager/src/utilities/pricing/dynamicPricing.ts @@ -21,6 +21,16 @@ export interface DataCenterPricingOptions { } export interface DataCenterPricingByTypeOptions { + /** + * The number of decimal places to return for the price. + * @default 2 + */ + decimalPrecision?: number; + /** + * The time period for which to find pricing data for (hourly or monthly). + * @default monthly + */ + interval?: 'hourly' | 'monthly'; /** * The `id` of the region we intended to get the price for. * @example us-east @@ -94,6 +104,8 @@ export const getDCSpecificPrice = ({ * @returns a data center specific price or undefined if this cannot be calculated */ export const getDCSpecificPriceByType = ({ + decimalPrecision = 2, + interval = 'monthly', regionId, size, type, @@ -101,19 +113,18 @@ export const getDCSpecificPriceByType = ({ if (!regionId || !type) { return undefined; } - // Apply the DC-specific price if it exists; otherwise, use the base price. const price = type.region_prices.find((region_price: RegionPrice) => { return region_price.id === regionId; - })?.monthly ?? type.price.monthly; + })?.[interval] ?? type.price?.[interval]; // If pricing is determined by size of the entity if (size && price) { - return (size * price).toFixed(2); + return (size * price).toFixed(decimalPrecision); } - return price?.toFixed(2) ?? undefined; + return price?.toFixed(decimalPrecision) ?? undefined; }; export const renderMonthlyPriceToCorrectDecimalPlace = ( From 32f836a0c1c079b291438b11ec0c619cd2a49bec Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-linode@users.noreply.github.com> Date: Thu, 23 May 2024 14:13:09 -0400 Subject: [PATCH 019/163] fix: [M3-8042] - Firewall landing device request with -1 ID (#10509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Fix bug where we were trying to fetch devices with an invalid Firewall id ## How to test 🧪 ### Reproduction steps (How to reproduce the issue, if applicable) - Go to the Firewalls landing page `/firewalls` and observe the devices network request. Notice failed device requests with a -1 firewall id ### Verification steps (How to verify changes) - Go to the Firewalls landing page `/firewalls` and observe the devices network request. Notice no more failed device requests --- .../.changeset/pr-10509-fixed-1716415172498.md | 5 +++++ .../Firewalls/FirewallLanding/FirewallDialog.tsx | 14 ++++++-------- .../FirewallLanding/FirewallLanding.tsx | 16 +++++++++------- 3 files changed, 20 insertions(+), 15 deletions(-) create mode 100644 packages/manager/.changeset/pr-10509-fixed-1716415172498.md diff --git a/packages/manager/.changeset/pr-10509-fixed-1716415172498.md b/packages/manager/.changeset/pr-10509-fixed-1716415172498.md new file mode 100644 index 00000000000..c0057a0a8c4 --- /dev/null +++ b/packages/manager/.changeset/pr-10509-fixed-1716415172498.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Firewall landing device request with -1 ID ([#10509](https://github.com/linode/manager/pull/10509)) diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx index 33fd6a16ce3..a8af6709fbc 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx @@ -1,11 +1,11 @@ +import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useQueryClient } from '@tanstack/react-query'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { queryKey as firewallQueryKey } from 'src/queries/firewalls'; import { useDeleteFirewall, useMutateFirewall } from 'src/queries/firewalls'; +import { queryKey as firewallQueryKey } from 'src/queries/firewalls'; import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; import { queryKey as nodebalancerQueryKey } from 'src/queries/nodebalancers'; @@ -17,7 +17,7 @@ interface Props { mode: Mode; onClose: () => void; open: boolean; - selectedFirewallId?: number; + selectedFirewallId: number; selectedFirewallLabel: string; } @@ -33,20 +33,18 @@ export const FirewallDialog = React.memo((props: Props) => { selectedFirewallLabel: label, } = props; - const { data: devices } = useAllFirewallDevicesQuery( - selectedFirewallId ?? -1 - ); + const { data: devices } = useAllFirewallDevicesQuery(selectedFirewallId); const { error: updateError, isLoading: isUpdating, mutateAsync: updateFirewall, - } = useMutateFirewall(selectedFirewallId ?? -1); + } = useMutateFirewall(selectedFirewallId); const { error: deleteError, isLoading: isDeleting, mutateAsync: deleteFirewall, - } = useDeleteFirewall(selectedFirewallId ?? -1); + } = useDeleteFirewall(selectedFirewallId); const requestMap = { delete: () => deleteFirewall(), diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx index 21ff9542266..7e37d83c7b8 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx @@ -175,13 +175,15 @@ const FirewallLanding = () => { onClose={onCloseCreateDrawer} open={isCreateFirewallDrawerOpen} /> - setIsModalOpen(false)} - open={isModalOpen} - selectedFirewallId={selectedFirewallId} - selectedFirewallLabel={selectedFirewall?.label ?? ''} - /> + {selectedFirewallId && ( + setIsModalOpen(false)} + open={isModalOpen} + selectedFirewallId={selectedFirewallId} + selectedFirewallLabel={selectedFirewall?.label ?? ''} + /> + )} ); }; From 819356b7895a6389ea83b63cfda309903cd1e185 Mon Sep 17 00:00:00 2001 From: Hussain Khalil <122488130+hkhalil-akamai@users.noreply.github.com> Date: Thu, 23 May 2024 16:09:29 -0400 Subject: [PATCH 020/163] feat: [M3-8016] - Add TagSelect to edit images drawer (#10466) * Add tags to images type * Add tag select to ImagesDrawer * Added changeset: TagSelect in Edit Image drawer * Added changeset: `tags` field in `Image` type * Further removal of unused logic * Add unit tests for ImagesDrawer * Fix unit tests * Fix action menu items * Add `tags` to `updateImageSchema` * feedback @abailly-akamai --- .../pr-10466-added-1715709689737.md | 5 + packages/api-v4/src/images/images.ts | 4 +- packages/api-v4/src/images/types.ts | 1 + .../pr-10466-added-1715709656767.md | 5 + .../src/components/TagsInput/TagsInput.tsx | 6 +- packages/manager/src/factories/images.ts | 1 + .../src/features/Images/ImagesActionMenu.tsx | 34 ++- .../src/features/Images/ImagesDrawer.test.tsx | 97 +++++++ .../src/features/Images/ImagesDrawer.tsx | 244 +++--------------- .../src/features/Images/ImagesLanding.tsx | 27 +- .../Linodes/LinodeSelect/LinodeSelect.tsx | 6 +- packages/manager/src/queries/images.ts | 6 +- .../pr-10466-added-1715830896341.md | 5 + packages/validation/src/images.schema.ts | 1 + 14 files changed, 194 insertions(+), 248 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10466-added-1715709689737.md create mode 100644 packages/manager/.changeset/pr-10466-added-1715709656767.md create mode 100644 packages/manager/src/features/Images/ImagesDrawer.test.tsx create mode 100644 packages/validation/.changeset/pr-10466-added-1715830896341.md diff --git a/packages/api-v4/.changeset/pr-10466-added-1715709689737.md b/packages/api-v4/.changeset/pr-10466-added-1715709689737.md new file mode 100644 index 00000000000..e7209cdbe61 --- /dev/null +++ b/packages/api-v4/.changeset/pr-10466-added-1715709689737.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +`tags` field in `Image` type ([#10466](https://github.com/linode/manager/pull/10466)) diff --git a/packages/api-v4/src/images/images.ts b/packages/api-v4/src/images/images.ts index 720d75bcdda..53b3d3d63e0 100644 --- a/packages/api-v4/src/images/images.ts +++ b/packages/api-v4/src/images/images.ts @@ -63,11 +63,13 @@ export const createImage = (data: CreateImagePayload) => { export const updateImage = ( imageId: string, label?: string, - description?: string + description?: string, + tags?: string[] ) => { const data = { ...(label && { label }), ...(description && { description }), + ...(tags && { tags }), }; return Request( diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index ed7e97c0080..eabad2806a4 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -22,6 +22,7 @@ export interface Image { expiry: null | string; status: ImageStatus; capabilities: ImageCapabilities[]; + tags: string[]; } export interface UploadImageResponse { diff --git a/packages/manager/.changeset/pr-10466-added-1715709656767.md b/packages/manager/.changeset/pr-10466-added-1715709656767.md new file mode 100644 index 00000000000..a9208e81d00 --- /dev/null +++ b/packages/manager/.changeset/pr-10466-added-1715709656767.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +TagSelect in Edit Image drawer ([#10466](https://github.com/linode/manager/pull/10466)) diff --git a/packages/manager/src/components/TagsInput/TagsInput.tsx b/packages/manager/src/components/TagsInput/TagsInput.tsx index 576a11d8e2d..e58aa7c6951 100644 --- a/packages/manager/src/components/TagsInput/TagsInput.tsx +++ b/packages/manager/src/components/TagsInput/TagsInput.tsx @@ -1,7 +1,7 @@ import { APIError } from '@linode/api-v4/lib/types'; +import { useQueryClient } from '@tanstack/react-query'; import { concat } from 'ramda'; import * as React from 'react'; -import { useQueryClient } from '@tanstack/react-query'; import Select, { Item, @@ -46,7 +46,7 @@ export interface TagsInputProps { /** * Callback fired when the value changes. */ - onChange: (selected: Item[]) => void; + onChange: (selected: Item[]) => void; /** * An error to display beneath the input. */ @@ -54,7 +54,7 @@ export interface TagsInputProps { /** * The value of the input. */ - value: Item[]; + value: Item[]; } export const TagsInput = (props: TagsInputProps) => { diff --git a/packages/manager/src/factories/images.ts b/packages/manager/src/factories/images.ts index 29094c54369..ec80a043440 100644 --- a/packages/manager/src/factories/images.ts +++ b/packages/manager/src/factories/images.ts @@ -14,6 +14,7 @@ export const imageFactory = Factory.Sync.makeFactory({ label: Factory.each((i) => `image-${i}`), size: 1500, status: 'available', + tags: [], type: 'manual', updated: new Date().toISOString(), vendor: null, diff --git a/packages/manager/src/features/Images/ImagesActionMenu.tsx b/packages/manager/src/features/Images/ImagesActionMenu.tsx index 357ab4a97b3..5e9b7ecefda 100644 --- a/packages/manager/src/features/Images/ImagesActionMenu.tsx +++ b/packages/manager/src/features/Images/ImagesActionMenu.tsx @@ -8,7 +8,12 @@ export interface Handlers { onCancelFailed?: (imageID: string) => void; onDelete?: (label: string, imageID: string, status?: ImageStatus) => void; onDeploy?: (imageID: string) => void; - onEdit?: (label: string, description: string, imageID: string) => void; + onEdit?: ( + label: string, + description: string, + imageID: string, + tags: string[] + ) => void; onRestore?: (imageID: string) => void; onRetry?: ( imageID: string, @@ -23,6 +28,7 @@ interface Props extends Handlers { id: string; label: string; status?: ImageStatus; + tags: string[]; } export const ImagesActionMenu = (props: Props) => { @@ -38,6 +44,7 @@ export const ImagesActionMenu = (props: Props) => { onRestore, onRetry, status, + tags, } = props; const actions: Action[] = React.useMemo(() => { @@ -47,24 +54,18 @@ export const ImagesActionMenu = (props: Props) => { return isFailed ? [ { - onClick: () => { - onRetry?.(id, label, description); - }, + onClick: () => onRetry?.(id, label, description), title: 'Retry', }, { - onClick: () => { - onCancelFailed?.(id); - }, + onClick: () => onCancelFailed?.(id), title: 'Cancel', }, ] : [ { disabled: isDisabled, - onClick: () => { - onEdit?.(label, description ?? ' ', id); - }, + onClick: () => onEdit?.(label, description ?? ' ', id, tags), title: 'Edit', tooltip: isDisabled ? 'Image is not yet available for use.' @@ -72,9 +73,7 @@ export const ImagesActionMenu = (props: Props) => { }, { disabled: isDisabled, - onClick: () => { - onDeploy?.(id); - }, + onClick: () => onDeploy?.(id), title: 'Deploy to New Linode', tooltip: isDisabled ? 'Image is not yet available for use.' @@ -82,18 +81,14 @@ export const ImagesActionMenu = (props: Props) => { }, { disabled: isDisabled, - onClick: () => { - onRestore?.(id); - }, + onClick: () => onRestore?.(id), title: 'Rebuild an Existing Linode', tooltip: isDisabled ? 'Image is not yet available for use.' : undefined, }, { - onClick: () => { - onDelete?.(label, id, status); - }, + onClick: () => onDelete?.(label, id, status), title: isAvailable ? 'Delete' : 'Cancel Upload', }, ]; @@ -109,6 +104,7 @@ export const ImagesActionMenu = (props: Props) => { onRetry, onCancelFailed, event, + tags, ]); return ( diff --git a/packages/manager/src/features/Images/ImagesDrawer.test.tsx b/packages/manager/src/features/Images/ImagesDrawer.test.tsx new file mode 100644 index 00000000000..32b4846720f --- /dev/null +++ b/packages/manager/src/features/Images/ImagesDrawer.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { linodeFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ImagesDrawer, Props } from './ImagesDrawer'; + +const props: Props = { + changeDescription: vi.fn(), + changeDisk: vi.fn(), + changeLabel: vi.fn(), + changeLinode: vi.fn(), + changeTags: vi.fn(), + mode: 'edit', + onClose: vi.fn(), + open: true, + selectedLinode: null, +}; + +describe('ImagesDrawer edit mode', () => { + it('should render', async () => { + const { getByText } = renderWithTheme( + + ); + + // Verify title renders + getByText('Edit Image'); + }); + + it('should allow editing image details', async () => { + const { getByLabelText, getByText } = renderWithTheme( + + ); + + fireEvent.change(getByLabelText('Label'), { + target: { value: 'test-image-label' }, + }); + + fireEvent.change(getByLabelText('Description'), { + target: { value: 'test description' }, + }); + + fireEvent.change(getByLabelText('Tags'), { + target: { value: 'new-tag' }, + }); + fireEvent.click(getByText('Create "new-tag"')); + + fireEvent.click(getByText('Save Changes')); + + expect(props.changeLabel).toBeCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ value: 'test-image-label' }), + }) + ); + + expect(props.changeDescription).toBeCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ value: 'test description' }), + }) + ); + + expect(props.changeTags).toBeCalledWith(['new-tag']); + }); +}); + +describe('ImagesDrawer restore mode', () => { + it('should render', async () => { + const { getByText } = renderWithTheme( + + ); + + // Verify title renders + getByText('Restore from Image'); + }); + + it('should allow editing image details', async () => { + const { findByText, getByRole, getByText } = renderWithTheme( + + ); + + server.use( + http.get('*/linode/instances', () => { + return HttpResponse.json(makeResourcePage(linodeFactory.buildList(5))); + }) + ); + + await userEvent.click(getByRole('combobox')); + await userEvent.click(await findByText('linode-1')); + await userEvent.click(getByText('Restore Image')); + + expect(props.changeLinode).toBeCalledWith(1); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesDrawer.tsx b/packages/manager/src/features/Images/ImagesDrawer.tsx index ac08178c47b..0dd808c7804 100644 --- a/packages/manager/src/features/Images/ImagesDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesDrawer.tsx @@ -1,167 +1,76 @@ -import { Disk, getLinodeDisks } from '@linode/api-v4/lib/linodes'; import { APIError } from '@linode/api-v4/lib/types'; -import { Theme } from '@mui/material/styles'; -import { useSnackbar } from 'notistack'; -import { equals } from 'ramda'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; +import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; -import { Typography } from 'src/components/Typography'; -import { IMAGE_DEFAULT_LIMIT } from 'src/constants'; -import { DiskSelect } from 'src/features/Linodes/DiskSelect/DiskSelect'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; -import { useEventsPollingActions } from 'src/queries/events/events'; -import { - useCreateImageMutation, - useUpdateImageMutation, -} from 'src/queries/images'; +import { useUpdateImageMutation } from 'src/queries/images'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; import { useImageAndLinodeGrantCheck } from './utils'; -const useStyles = makeStyles()((theme: Theme) => ({ - actionPanel: { - marginTop: theme.spacing(2), - }, - helperText: { - paddingTop: theme.spacing(0.5), - }, - root: {}, - suffix: { - fontSize: '.9rem', - marginRight: theme.spacing(1), - }, -})); - export interface Props { changeDescription: (e: React.ChangeEvent) => void; changeDisk: (disk: null | string) => void; changeLabel: (e: React.ChangeEvent) => void; changeLinode: (linodeId: number) => void; + changeTags: (tags: string[]) => void; description?: string; - // Only used from LinodeDisks to pre-populate the selected Disk - disks?: Disk[]; imageId?: string; label?: string; mode: DrawerMode; onClose: () => void; - open: boolean; - selectedDisk: null | string; + open?: boolean; selectedLinode: null | number; + tags?: string[]; } type CombinedProps = Props; -export type DrawerMode = 'closed' | 'create' | 'edit' | 'imagize' | 'restore'; - -const createImageText = 'Create Image'; +export type DrawerMode = 'edit' | 'restore'; const titleMap: Record = { - closed: '', - create: createImageText, edit: 'Edit Image', - imagize: createImageText, restore: 'Restore from Image', }; const buttonTextMap: Record = { - closed: '', - create: createImageText, edit: 'Save Changes', - imagize: createImageText, restore: 'Restore Image', }; export const ImagesDrawer = (props: CombinedProps) => { const { changeDescription, - changeDisk, changeLabel, changeLinode, + changeTags, description, imageId, label, mode, onClose, open, - selectedDisk, selectedLinode, + tags, } = props; - const { classes } = useStyles(); - const { enqueueSnackbar } = useSnackbar(); const history = useHistory(); const { canCreateImage, permissionedLinodes: availableLinodes, } = useImageAndLinodeGrantCheck(); - const { checkForNewEvents } = useEventsPollingActions(); - - const [mounted, setMounted] = React.useState(false); const [notice, setNotice] = React.useState(undefined); const [submitting, setSubmitting] = React.useState(false); const [errors, setErrors] = React.useState(undefined); - const [disks, setDisks] = React.useState([]); - const { mutateAsync: updateImage } = useUpdateImageMutation(); - const { mutateAsync: createImage } = useCreateImageMutation(); - - React.useEffect(() => { - setMounted(true); - - if (props.disks) { - // for the 'imagizing' mode - setDisks(props.disks); - } - - return () => { - setMounted(false); - }; - }, [props.disks]); - - React.useEffect(() => { - if (!selectedLinode) { - setDisks([]); - } - - if (selectedLinode) { - getLinodeDisks(selectedLinode) - .then((response) => { - if (!mounted) { - return; - } - - const filteredDisks = response.data.filter( - (disk) => disk.filesystem !== 'swap' - ); - if (!equals(disks, filteredDisks)) { - setDisks(filteredDisks); - } - }) - .catch((_) => { - if (!mounted) { - return; - } - - if (mounted) { - setErrors([ - { - field: 'disk_id', - reason: 'Could not retrieve disks for this Linode.', - }, - ]); - } - }); - } - }, [selectedLinode]); const handleLinodeChange = (linodeID: number) => { // Clear any errors @@ -169,21 +78,6 @@ export const ImagesDrawer = (props: CombinedProps) => { changeLinode(linodeID); }; - const handleDiskChange = (diskID: null | string) => { - // Clear any errors - setErrors(undefined); - changeDisk(diskID); - }; - - const close = () => { - onClose(); - if (mounted) { - setErrors(undefined); - setNotice(undefined); - setSubmitting(false); - } - }; - const safeDescription = description ? description : ' '; const onSubmit = () => { @@ -198,60 +92,16 @@ export const ImagesDrawer = (props: CombinedProps) => { return; } - updateImage({ description: safeDescription, imageId, label }) - .then(() => { - if (!mounted) { - return; - } - - close(); - }) + updateImage({ description: safeDescription, imageId, label, tags }) + .then(() => Promise.reject([{ reason: 'Unable to edit Image' }])) + .then(onClose) .catch((errorResponse: APIError[]) => { - if (!mounted) { - return; - } - - setSubmitting(false); setErrors( getAPIErrorOrDefault(errorResponse, 'Unable to edit Image') ); - }); - return; - - case 'create': - case 'imagize': - createImage({ - description: safeDescription, - disk_id: Number(selectedDisk), - label, - }) - .then(() => { - if (!mounted) { - return; - } - - checkForNewEvents(); - - setSubmitting(false); - - close(); - - enqueueSnackbar('Image scheduled for creation.', { - variant: 'info', - }); }) - .catch((errorResponse: APIError[]) => { - if (!mounted) { - return; - } - + .finally(() => { setSubmitting(false); - setErrors( - getAPIErrorOrDefault( - errorResponse, - 'There was an error creating the image.' - ) - ); }); return; @@ -271,26 +121,6 @@ export const ImagesDrawer = (props: CombinedProps) => { } }; - const checkRequirements = () => { - // When creating an image, disable the submit button until a Linode and - // disk are selected. When restoring to an existing Linode, the Linode select is the only field. - // When imagizing, the Linode is selected already so only check for a disk selection. - const isDiskSelected = Boolean(selectedDisk); - - switch (mode) { - case 'create': - return !(isDiskSelected && selectedLinode); - case 'imagize': - return !isDiskSelected; - case 'restore': - return !selectedLinode; - default: - return false; - } - }; - - const requirementsMet = checkRequirements(); - const hasErrorFor = getAPIErrorFor( { disk_id: 'Disk', @@ -305,10 +135,17 @@ export const ImagesDrawer = (props: CombinedProps) => { const descriptionError = hasErrorFor('description'); const generalError = hasErrorFor('none'); const linodeError = hasErrorFor('linode_id'); - const diskError = hasErrorFor('disk_id'); + const tagsError = hasErrorFor('tags'); return ( - + { + setErrors(undefined); + }} + onClose={onClose} + open={open} + title={titleMap[mode]} + > {!canCreateImage ? ( { {notice && } - {['create', 'restore'].includes(mode) && ( + {mode === 'restore' && ( { if (linode !== null) { @@ -338,30 +175,8 @@ export const ImagesDrawer = (props: CombinedProps) => { /> )} - {['create', 'imagize'].includes(mode) && ( + {mode === 'edit' && ( <> - - - Linode Images are limited to {IMAGE_DEFAULT_LIMIT} MB of data per - disk by default. Please ensure that your disk content does not - exceed this size limit, or open a Support ticket to request a higher - limit. Additionally, Linode Images cannot be created if you are - using raw disks or disks that have been formatted using custom - filesystems. - - - )} - - {['create', 'edit', 'imagizing'].includes(mode) && ( - { rows={1} value={description} /> - + changeTags(tags.map((tag) => tag.value))} + tagError={tagsError} + value={tags?.map((t) => ({ label: t, value: t })) ?? []} + /> + )} { 'data-testid': 'cancel', disabled: !canCreateImage, label: 'Cancel', - onClick: close, + onClick: onClose, }} style={{ marginTop: 16 }} /> diff --git a/packages/manager/src/features/Images/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding.tsx index 48f10fa6da6..d52729e5923 100644 --- a/packages/manager/src/features/Images/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding.tsx @@ -66,8 +66,8 @@ interface ImageDrawerState { label?: string; mode: DrawerMode; open: boolean; - selectedDisk: null | string; selectedLinode?: number; + tags?: string[]; } interface ImageDialogState { @@ -81,12 +81,12 @@ interface ImageDialogState { interface ImagesLandingProps extends ImageDrawerState, ImageDialogState {} -const defaultDrawerState = { +const defaultDrawerState: ImageDrawerState = { description: '', label: '', - mode: 'edit' as DrawerMode, + mode: 'edit', open: false, - selectedDisk: null, + tags: [], }; const defaultDialogState = { @@ -290,14 +290,19 @@ export const ImagesLanding: React.FC = () => { queryClient.invalidateQueries(imageQueries.paginated._def); }; - const openForEdit = (label: string, description: string, imageID: string) => { + const openForEdit = ( + label: string, + description: string, + imageID: string, + tags: string[] + ) => { setDrawer({ description, imageID, label, mode: 'edit', open: true, - selectedDisk: null, + tags, }); }; @@ -306,7 +311,6 @@ export const ImagesLanding: React.FC = () => { imageID, mode: 'restore', open: true, - selectedDisk: null, }); }; @@ -350,6 +354,12 @@ export const ImagesLanding: React.FC = () => { })); }; + const setTags = (tags: string[]) => + setDrawer((prevDrawerState) => ({ + ...prevDrawerState, + tags, + })); + const getActions = () => { return ( = () => { changeDisk={changeSelectedDisk} changeLabel={setLabel} changeLinode={changeSelectedLinode} + changeTags={setTags} description={drawer.description} imageId={drawer.imageID} label={drawer.label} mode={drawer.mode} onClose={closeImageDrawer} open={drawer.open} - selectedDisk={drawer.selectedDisk} selectedLinode={drawer.selectedLinode || null} + tags={drawer.tags} /> ); }; diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx index c51f9aa9f70..84722abb76d 100644 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx @@ -104,7 +104,7 @@ export const LinodeSelect = ( value, } = props; - const { data, error, isLoading } = useAllLinodesQuery({}, filter); + const { data, error, isFetching } = useAllLinodesQuery({}, filter, !options); const [inputValue, setInputValue] = React.useState(''); @@ -131,7 +131,7 @@ export const LinodeSelect = ( : undefined } noOptionsText={ - noOptionsMessage ?? getDefaultNoOptionsMessage(error, isLoading) + noOptionsMessage ?? getDefaultNoOptionsMessage(error, isFetching) } onChange={(_, value) => multiple && Array.isArray(value) @@ -177,7 +177,7 @@ export const LinodeSelect = ( id={id} inputValue={inputValue} label={label ? label : multiple ? 'Linodes' : 'Linode'} - loading={isLoading || loading} + loading={isFetching || loading} multiple={multiple} noMarginTop={noMarginTop} onBlur={onBlur} diff --git a/packages/manager/src/queries/images.ts b/packages/manager/src/queries/images.ts index 4e678cf646b..0fc3ea99859 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/manager/src/queries/images.ts @@ -80,10 +80,10 @@ export const useUpdateImageMutation = () => { return useMutation< Image, APIError[], - { description?: string; imageId: string; label?: string } + { description?: string; imageId: string; label?: string; tags?: string[] } >({ - mutationFn: ({ description, imageId, label }) => - updateImage(imageId, label, description), + mutationFn: ({ description, imageId, label, tags }) => + updateImage(imageId, label, description, tags), onSuccess(image) { queryClient.invalidateQueries(imageQueries.paginated._def); queryClient.setQueryData( diff --git a/packages/validation/.changeset/pr-10466-added-1715830896341.md b/packages/validation/.changeset/pr-10466-added-1715830896341.md new file mode 100644 index 00000000000..d3c1a90b5e4 --- /dev/null +++ b/packages/validation/.changeset/pr-10466-added-1715830896341.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Added +--- + +`tags` to `updateImageSchema` ([#10466](https://github.com/linode/manager/pull/10466)) diff --git a/packages/validation/src/images.schema.ts b/packages/validation/src/images.schema.ts index bd5e64ab4da..4ff45ebef4c 100644 --- a/packages/validation/src/images.schema.ts +++ b/packages/validation/src/images.schema.ts @@ -31,4 +31,5 @@ export const updateImageSchema = object({ description: string() .notRequired() .max(65000, 'Length must be 65000 characters or less.'), + tags: array(string()).notRequired(), }); From d5e7bd9c47ad50a2bdfa812fb81db620597d1ca7 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 23 May 2024 16:51:31 -0400 Subject: [PATCH 021/163] fix: Linode Create v2 - UDFs (#10507) * fix multiselect * fixes * hide placeholder text when options are selected @hana-linode --------- Co-authored-by: Banks Nussman --- .../UserDefinedFieldInput.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.tsx index 4e8c63e0ac5..1dc9dc84a1b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput.tsx @@ -57,8 +57,15 @@ export const UserDefinedFieldInput = ({ userDefinedField }: Props) => { .manyof!.split(',') .map((option) => ({ label: option })); + const value = options.filter((option) => + field.value?.split(',').includes(option.label) + ); + return ( { + field.onChange(options.map((option) => option.label).join(',')); + }} textFieldProps={{ required: isRequired, }} @@ -66,9 +73,10 @@ export const UserDefinedFieldInput = ({ userDefinedField }: Props) => { label={userDefinedField.label} multiple noMarginTop - onChange={(e, options) => field.onChange(options.join(','))} options={options} - value={field.value?.split(',') ?? []} + // If options are selected, hide the placeholder + placeholder={value.length > 0 ? ' ' : undefined} + value={value} /> ); } @@ -78,6 +86,8 @@ export const UserDefinedFieldInput = ({ userDefinedField }: Props) => { .oneof!.split(',') .map((option) => ({ label: option })); + const value = options.find((option) => option.label === field.value); + if (options.length > 4) { return ( { }} disableClearable label={userDefinedField.label} - onChange={(_, option) => field.onChange(option.label)} + onChange={(_, option) => field.onChange(option?.label ?? '')} options={options} - value={options.find((option) => option.label === field.value)} + value={value} /> ); } From 7b2220e5503cf37aa950c6be7458b8085e38920e Mon Sep 17 00:00:00 2001 From: Azure-akamai Date: Fri, 24 May 2024 09:12:18 -0400 Subject: [PATCH 022/163] test: [M3-7557] - remove console logs from e2e tests (#10506) * remove console logs from e2e tests * Added changeset: remove console logs from e2e tests * revert installLongview() change --- .../manager/.changeset/pr-10506-tests-1716399579811.md | 5 +++++ .../e2e/core/images/create-linode-from-image.spec.ts | 2 -- .../manager/cypress/e2e/core/longview/longview.spec.ts | 8 +------- 3 files changed, 6 insertions(+), 9 deletions(-) create mode 100644 packages/manager/.changeset/pr-10506-tests-1716399579811.md diff --git a/packages/manager/.changeset/pr-10506-tests-1716399579811.md b/packages/manager/.changeset/pr-10506-tests-1716399579811.md new file mode 100644 index 00000000000..50fdc3c1fa1 --- /dev/null +++ b/packages/manager/.changeset/pr-10506-tests-1716399579811.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +remove console logs from e2e tests ([#10506](https://github.com/linode/manager/pull/10506)) diff --git a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts index 5cec5a2b3ab..c6ae84c86ab 100644 --- a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts @@ -53,8 +53,6 @@ const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { cy.wait('@mockLinodeRequest'); - console.log('mockLinode', mockLinode); - fbtVisible(mockLinode.label); fbtVisible(region.label); fbtVisible(`${mockLinode.id}`); diff --git a/packages/manager/cypress/e2e/core/longview/longview.spec.ts b/packages/manager/cypress/e2e/core/longview/longview.spec.ts index d45259e8a6a..06c8ee71061 100644 --- a/packages/manager/cypress/e2e/core/longview/longview.spec.ts +++ b/packages/manager/cypress/e2e/core/longview/longview.spec.ts @@ -155,13 +155,7 @@ describe('longview', () => { }); // Install Longview on Linode by SSHing into machine and executing cURL command. - installLongview(linodeIp, linodePassword, installCommand).then( - (output) => { - // TODO Output this to a log file. - console.log(output.stdout); - console.log(output.stderr); - } - ); + installLongview(linodeIp, linodePassword, installCommand); // Wait for Longview to begin serving data and confirm that Cloud Manager // UI updates accordingly. From 65249071b9414e45b8122da94d079fc62c0902bb Mon Sep 17 00:00:00 2001 From: santoshp210 <159890961+santoshp210@users.noreply.github.com> Date: Fri, 24 May 2024 18:48:23 +0530 Subject: [PATCH 023/163] upcoming: [DI-18311] - Add CloudPulse feature flag and landing page to Cloud Manager (#10393) * upcoming: [DI-18311] - Adding CloudPulse section to Cloud Manager * upcoming: [DI-18311] - Adding CloudPulse section to Cloud Manager * upcoming: [DI-18311] - Adding CloudPulse section to Cloud Manager * upcoming: [DI-18311] - Adding CloudPulse section to Cloud Manager: Removing unused Component * upcoming: [DI-18311] - Minor changes * upcoming: [DI-18311] - Added changeset * upcoming: [DI-18311] - Addressed Review changes * upcoming: [DI-18311] - Renamed the Feature Flag as per LD changes * upcoming: [DI-18311] - Renamed the flag from CloudPulseFlag to AclpFlag * upcoming: [DI-18311] - added beta boolean to the Feature Flag object --- .../pr-10393-upcoming-features-1713795855499.md | 5 +++++ packages/manager/src/MainContent.tsx | 14 ++++++++++++++ packages/manager/src/assets/icons/cloudpulse.svg | 8 ++++++++ .../src/components/PrimaryNav/PrimaryNav.tsx | 14 +++++++++++++- packages/manager/src/dev-tools/FeatureFlagTool.tsx | 1 + packages/manager/src/featureFlags.ts | 6 ++++++ .../src/features/CloudPulse/CloudPulseLanding.tsx | 13 +++++++++++++ 7 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-10393-upcoming-features-1713795855499.md create mode 100644 packages/manager/src/assets/icons/cloudpulse.svg create mode 100644 packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx diff --git a/packages/manager/.changeset/pr-10393-upcoming-features-1713795855499.md b/packages/manager/.changeset/pr-10393-upcoming-features-1713795855499.md new file mode 100644 index 00000000000..f835d57a3fb --- /dev/null +++ b/packages/manager/.changeset/pr-10393-upcoming-features-1713795855499.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add CloudPulse feature flag and landing page([#10393](https://github.com/linode/manager/pull/10393)) diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index 0594cef7b01..39de2ed2685 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -179,6 +179,12 @@ const PlacementGroups = React.lazy(() => })) ); +const CloudPulse = React.lazy(() => + import('src/features/CloudPulse/CloudPulseLanding').then((module) => ({ + default: module.CloudPulseLanding, + })) +); + export const MainContent = () => { const { classes, cx } = useStyles(); const flags = useFlags(); @@ -214,6 +220,8 @@ export const MainContent = () => { const { data: accountSettings } = useAccountSettings(); + const showCloudPulse = Boolean(flags.aclp?.enabled); + const defaultRoot = accountSettings?.managed ? '/managed' : '/linodes'; /** @@ -349,6 +357,12 @@ export const MainContent = () => { )} + {showCloudPulse && ( + + )} {/** We don't want to break any bookmarks. This can probably be removed eventually. */} diff --git a/packages/manager/src/assets/icons/cloudpulse.svg b/packages/manager/src/assets/icons/cloudpulse.svg new file mode 100644 index 00000000000..49058da0882 --- /dev/null +++ b/packages/manager/src/assets/icons/cloudpulse.svg @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index b23c8067b41..2921b0c7fcf 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { Link, LinkProps, useLocation } from 'react-router-dom'; import Account from 'src/assets/icons/account.svg'; +import CloudPulse from 'src/assets/icons/cloudpulse.svg'; import Beta from 'src/assets/icons/entityIcons/beta.svg'; import Storage from 'src/assets/icons/entityIcons/bucket.svg'; import Database from 'src/assets/icons/entityIcons/database.svg'; @@ -58,6 +59,7 @@ type NavEntity = | 'Longview' | 'Managed' | 'Marketplace' + | 'Monitor' | 'NodeBalancers' | 'Object Storage' | 'Placement Groups' @@ -155,7 +157,9 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const allowMarketplacePrefetch = !oneClickApps && !oneClickAppsLoading && !oneClickAppsError; - +; + const showCloudPulse = Boolean(flags.aclp?.enabled); + const { isACLBEnabled } = useIsACLBEnabled(); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isDatabasesEnabled } = useIsDatabasesEnabled(); @@ -275,6 +279,13 @@ export const PrimaryNav = (props: PrimaryNavProps) => { href: '/longview', icon: , }, + { + display: 'Monitor', + hide: !showCloudPulse, + href: '/monitor/cloudpulse', + icon: , + isBeta: flags.aclp?.beta, + }, { attr: { 'data-qa-one-click-nav-btn': true }, display: 'Marketplace', @@ -313,6 +324,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { isACLBEnabled, isPlacementGroupsEnabled, flags.placementGroups, + showCloudPulse, ] ); diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 1a96f9f0f08..69ab0b57250 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -20,6 +20,7 @@ const MOCK_FEATURE_FLAGS_STORAGE_KEY = 'devTools/mock-feature-flags'; const options: { flag: keyof Flags; label: string }[] = [ { flag: 'aclb', label: 'ACLB' }, { flag: 'aclbFullCreateFlow', label: 'ACLB Full Create Flow' }, + { flag: 'aclp', label: 'CloudPulse' }, { flag: 'disableLargestGbPlans', label: 'Disable Largest GB Plans' }, { flag: 'gecko2', label: 'Gecko' }, { flag: 'linodeCreateRefactor', label: 'Linode Create v2' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 99353bc972b..775f369cb8b 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -48,6 +48,11 @@ interface GeckoFlag { ga: boolean; } +interface AclpFlag { + beta: boolean; + enabled: boolean; +} + interface gpuV2 { planDivider: boolean; } @@ -57,6 +62,7 @@ type OneClickApp = Record; export interface Flags { aclb: boolean; aclbFullCreateFlow: boolean; + aclp: AclpFlag; apiMaintenance: APIMaintenance; databaseBeta: boolean; databaseResize: boolean; diff --git a/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx b/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx new file mode 100644 index 00000000000..1ee9bb770c7 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; + +import { LandingHeader } from 'src/components/LandingHeader/LandingHeader'; +import { Paper } from 'src/components/Paper'; + +export const CloudPulseLanding = () => { + return ( + <> + + + + ); +}; From 5bf015efc2ede8103b8b1e11f7616a62637bb464 Mon Sep 17 00:00:00 2001 From: Hussain Khalil <122488130+hkhalil-akamai@users.noreply.github.com> Date: Fri, 24 May 2024 11:38:04 -0400 Subject: [PATCH 024/163] Delete accidentally committed testing error (#10514) --- packages/manager/src/features/Images/ImagesDrawer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/manager/src/features/Images/ImagesDrawer.tsx b/packages/manager/src/features/Images/ImagesDrawer.tsx index 0dd808c7804..444213ccaed 100644 --- a/packages/manager/src/features/Images/ImagesDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesDrawer.tsx @@ -93,7 +93,6 @@ export const ImagesDrawer = (props: CombinedProps) => { } updateImage({ description: safeDescription, imageId, label, tags }) - .then(() => Promise.reject([{ reason: 'Unable to edit Image' }])) .then(onClose) .catch((errorResponse: APIError[]) => { setErrors( From 432909f71f2e6bd5e216141325f8efa1ff256cb9 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Fri, 24 May 2024 12:50:34 -0700 Subject: [PATCH 025/163] feat: [M3-8099] - Improve Linode Clone Power Off based on initial data (#10508) * Remove duplicate test * Swap notice list order and bold key words * Add analytics event on power off button * Add status and power off button to linode selection card * Prevent power off button from displaying on cards outside clone flow * Add test coverage for SelectLinodeCard * Added changeset: Improvements to Clone flow to encourage powering down before cloning * Address feedback: add missing event in v2 flow * Address feedback: improve alignment --- .../pr-10508-added-1716415682879.md | 5 ++ .../Tabs/Clone/CloneWarning.tsx | 9 ++- .../shared/LinodeSelectTable.tsx | 16 +++- .../SelectLinodeCard.test.tsx | 76 +++++++++++++++++++ .../SelectLinodePanel/SelectLinodeCard.tsx | 35 +++++++++ .../SelectLinodePanel/SelectLinodeCards.tsx | 4 + .../SelectLinodePanel.test.tsx | 15 ---- .../SelectLinodePanel/SelectLinodePanel.tsx | 10 ++- .../TabbedContent/FromLinodeContent.tsx | 6 +- .../analytics/customEventAnalytics.ts | 10 +++ 10 files changed, 160 insertions(+), 26 deletions(-) create mode 100644 packages/manager/.changeset/pr-10508-added-1716415682879.md create mode 100644 packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCard.test.tsx diff --git a/packages/manager/.changeset/pr-10508-added-1716415682879.md b/packages/manager/.changeset/pr-10508-added-1716415682879.md new file mode 100644 index 00000000000..963f40b2866 --- /dev/null +++ b/packages/manager/.changeset/pr-10508-added-1716415682879.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Improvements to Clone flow to encourage powering down before cloning ([#10508](https://github.com/linode/manager/pull/10508)) diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Clone/CloneWarning.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Clone/CloneWarning.tsx index b8af8b69cd0..d9ba1436410 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Clone/CloneWarning.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Clone/CloneWarning.tsx @@ -9,12 +9,13 @@ export const CloneWarning = () => { - This newly created Linode will be created with the same password and - SSH Keys (if any) as the original Linode. + To help avoid data corruption during the cloning + process, we recommend powering off your Compute Instance prior to + cloning. - To help avoid data corruption during the cloning process, we recommend - powering off your Compute Instance prior to cloning. + This newly created Linode will be created with the same password and + SSH Keys (if any) as the original Linode. diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx index 41f10c8a424..509ef9c7c34 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx @@ -23,6 +23,7 @@ import { PowerActionsDialog } from 'src/features/Linodes/PowerActionsDialogOrDra import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useLinodesQuery } from 'src/queries/linodes/linodes'; +import { sendLinodePowerOffEvent } from 'src/utilities/analytics/customEventAnalytics'; import { privateIPRegex } from 'src/utilities/ipUtils'; import { isNumeric } from 'src/utilities/stringUtils'; @@ -37,7 +38,8 @@ import type { Theme } from '@mui/material'; interface Props { /** - * Adds an extra column that will dispay a "power off" option when the row is selected + * In desktop view, adds an extra column that will display a "power off" option when the row is selected. + * In mobile view, allows the "power off" button to display when the card is selected. */ enablePowerOff?: boolean; } @@ -102,6 +104,11 @@ export const LinodeSelectTable = (props: Props) => { })); }; + const handlePowerOff = (linode: Linode) => { + setLinodeToPowerOff(linode); + sendLinodePowerOffEvent('Clone Linode'); + }; + const columns = enablePowerOff ? 6 : 5; return ( @@ -162,7 +169,10 @@ export const LinodeSelectTable = (props: Props) => { setLinodeToPowerOff(linode) + ? () => { + setLinodeToPowerOff(linode); + sendLinodePowerOffEvent('Clone Linode'); + } : undefined } key={linode.id} @@ -177,10 +187,12 @@ export const LinodeSelectTable = (props: Props) => { {data?.data.map((linode) => ( handlePowerOff(linode)} handleSelection={() => handleSelect(linode)} key={linode.id} linode={linode} selected={linode.id === field.value?.id} + showPowerActions={Boolean(enablePowerOff)} /> ))} {data?.results === 0 && ( diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCard.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCard.test.tsx new file mode 100644 index 00000000000..787793169c0 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCard.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent } from '@testing-library/react'; +import React from 'react'; + +import { linodeFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { SelectLinodeCard } from './SelectLinodeCard'; + +const mockPoweredOnLinode = linodeFactory.build({ status: 'running' }); +const mockPoweredOffLinode = linodeFactory.build({ status: 'offline' }); + +const defaultProps = { + disabled: false, + handlePowerOff: vi.fn(), + handleSelection: vi.fn(), + key: mockPoweredOnLinode.id, + linode: mockPoweredOnLinode, + selected: false, + showPowerActions: false, +}; + +describe('SelectLinodeCard', () => { + it('displays the status of a linode', () => { + const { getByLabelText, getByText, queryByRole } = renderWithTheme( + + ); + expect(getByLabelText('Linode status running')).toBeInTheDocument(); + expect(getByText('Running')).toBeVisible(); + // Should not display the Power Off button unless the card is selected. + expect(queryByRole('button')).not.toBeInTheDocument(); + }); + + it('displays the status and Power Off button of a linode that is selected and running when power actions should be shown', () => { + const { getByLabelText, getByRole, getByText } = renderWithTheme( + + ); + + expect(getByLabelText('Linode status running')).toBeInTheDocument(); + expect(getByText('Running')).toBeVisible(); + + const powerOffButton = getByRole('button'); + expect(powerOffButton).toHaveTextContent('Power Off'); + fireEvent.click(powerOffButton); + expect(defaultProps.handlePowerOff).toHaveBeenCalledTimes(1); + }); + + it('does not display the Power Off button when it should not be shown', () => { + const { queryByRole } = renderWithTheme( + + ); + expect(queryByRole('button')).not.toBeInTheDocument(); + }); + + it('does not display the Power Off button when a selected linode is powered off', () => { + const { getByLabelText, getByText, queryByRole } = renderWithTheme( + + ); + expect(getByLabelText('Linode status offline')).toBeInTheDocument(); + expect(getByText('Offline')).toBeVisible(); + // Should not display the Power Off button unless the linode is running. + expect(queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCard.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCard.tsx index e5bdc341ce5..434c1c4b4d9 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCard.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCard.tsx @@ -1,26 +1,37 @@ import { Linode } from '@linode/api-v4'; +import Grid from '@mui/material/Unstable_Grid2'; import React from 'react'; +import { Button } from 'src/components/Button/Button'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; +import { Stack } from 'src/components/Stack'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useImageQuery } from 'src/queries/images'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useTypeQuery } from 'src/queries/types'; +import { capitalizeAllWords } from 'src/utilities/capitalize'; import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; import { isNotNullOrUndefined } from 'src/utilities/nullOrUndefined'; +import { getLinodeIconStatus } from '../../LinodesLanding/utils'; + interface Props { disabled?: boolean; + handlePowerOff: () => void; handleSelection: () => void; linode: Linode; selected?: boolean; + showPowerActions: boolean; } export const SelectLinodeCard = ({ disabled, + handlePowerOff, handleSelection, linode, selected, + showPowerActions, }: Props) => { const { data: regions } = useRegionsQuery(); @@ -40,12 +51,35 @@ export const SelectLinodeCard = ({ id: linode?.id, }); + const iconStatus = getLinodeIconStatus(linode?.status); + const shouldShowPowerButton = + showPowerActions && linode?.status === 'running' && selected; + const type = linodeType ? formatStorageUnits(linodeType?.label) : linode.type; const image = linodeImage?.label ?? linode.image; const region = regions?.find((region) => region.id == linode.region)?.label ?? linode.region; + const renderVariant = () => ( + + + + + {capitalizeAllWords(linode.status.replace('_', ' '))} + + {shouldShowPowerButton && ( + + )} + + + ); + return ( ); }; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCards.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCards.tsx index 75a75589bcc..689764c6bdf 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCards.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodeCards.tsx @@ -8,7 +8,9 @@ import { RenderLinodeProps } from './SelectLinodePanel'; export const SelectLinodeCards = ({ disabled, + handlePowerOff, handleSelection, + showPowerActions, orderBy: { data: linodes }, selectedLinodeId, }: RenderLinodeProps) => ( @@ -20,9 +22,11 @@ export const SelectLinodeCards = ({ handleSelection(linode.id, linode.type, linode.specs.disk) } disabled={disabled} + handlePowerOff={() => handlePowerOff(linode.id)} key={linode.id} linode={linode} selected={linode.id == selectedLinodeId} + showPowerActions={showPowerActions} /> )) ) : ( diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.test.tsx index ffb91bec104..6fd81d073f0 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.test.tsx @@ -197,21 +197,6 @@ describe('SelectLinodePanel (cards, mobile)', () => { ).toHaveTextContent(defaultProps.linodes[0].label); }); - it('displays the heading, notices and error', () => { - const { getByText } = renderWithTheme( - - ); - - expect(getByText('Example error')).toBeInTheDocument(); - expect(getByText('Example header')).toBeInTheDocument(); - expect(getByText('Example notice')).toBeInTheDocument(); - }); - it('prefills the search box when mounted with a selected linode', async () => { setupMocks(); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx index b252fd196aa..7fc4a1a78d0 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx @@ -15,6 +15,7 @@ import { Paper } from 'src/components/Paper'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { useOrder } from 'src/hooks/useOrder'; +import { sendLinodePowerOffEvent } from 'src/utilities/analytics/customEventAnalytics'; import { PowerActionsDialog } from '../../PowerActionsDialogOrDrawer'; import { SelectLinodeCards } from './SelectLinodeCards'; @@ -26,7 +27,7 @@ interface Props { handleSelection: (id: number, type: null | string, diskSize?: number) => void; header?: string; linodes: Linode[]; - notices?: string[]; + notices?: (JSX.Element | string)[]; selectedLinodeID?: number; showPowerActions?: boolean; } @@ -155,9 +156,10 @@ export const SelectLinodePanel = (props: Props) => { /> - setPowerOffLinode({ linodeId }) - } + handlePowerOff={(linodeId) => { + setPowerOffLinode({ linodeId }); + sendLinodePowerOffEvent('Clone Linode'); + }} orderBy={{ data: linodesData, handleOrderChange, diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx index 0e739645ff3..8ced5a5a880 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx @@ -106,8 +106,12 @@ export const FromLinodeContent = (props: CombinedProps) => { + To help avoid data corruption during the + cloning process, we recommend powering off your Compute Instance + prior to cloning. + , 'This newly created Linode will be created with the same password and SSH Keys (if any) as the original Linode.', - 'To help avoid data corruption during the cloning process, we recommend powering off your Compute Instance prior to cloning.', ]} data-qa-linode-panel disabled={userCannotCreateLinode} diff --git a/packages/manager/src/utilities/analytics/customEventAnalytics.ts b/packages/manager/src/utilities/analytics/customEventAnalytics.ts index 7c794790ef4..2091f5578dd 100644 --- a/packages/manager/src/utilities/analytics/customEventAnalytics.ts +++ b/packages/manager/src/utilities/analytics/customEventAnalytics.ts @@ -469,3 +469,13 @@ export const sendManageGravatarEvent = () => { label: 'Manage photo', }); }; + +// SelectLinodePanel.tsx +// LinodeSelectTable.tsx +export const sendLinodePowerOffEvent = (category: string) => { + sendEvent({ + action: 'Click:button', + category, + label: 'Power Off', + }); +}; From b6c409463ac75fff183a3f4a31ef9122f9d2ce1f Mon Sep 17 00:00:00 2001 From: Azure-akamai Date: Tue, 28 May 2024 09:10:02 -0400 Subject: [PATCH 026/163] test: [M3-6609] - Add Linode details page assertion for LISH via SSH Info (#10513) * Add Linode details page assertion for LISH via SSH Info * update comments * update test * update test * Added changeset: Add Linode details page assertion for LISH via SSH Info * updates after reviews --- .../pr-10513-tests-1716492994025.md | 5 +++++ .../e2e/core/linodes/create-linode.spec.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 packages/manager/.changeset/pr-10513-tests-1716492994025.md diff --git a/packages/manager/.changeset/pr-10513-tests-1716492994025.md b/packages/manager/.changeset/pr-10513-tests-1716492994025.md new file mode 100644 index 00000000000..5b804c92d9f --- /dev/null +++ b/packages/manager/.changeset/pr-10513-tests-1716492994025.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Linode details page assertion for LISH via SSH Info ([#10513](https://github.com/linode/manager/pull/10513)) diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index 92c99fa4884..1945263f5e0 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -15,6 +15,9 @@ import { } from 'support/intercepts/feature-flags'; import { interceptCreateLinode } from 'support/intercepts/linodes'; import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { interceptGetProfile } from 'support/intercepts/profile'; + +let username: string; authenticate(); describe('Create Linode', () => { @@ -70,6 +73,8 @@ describe('Create Linode', () => { }); const linodeLabel = randomLabel(); + interceptGetProfile().as('getProfile'); + interceptCreateLinode().as('createLinode'); cy.visitWithLogin('/linodes/create'); @@ -116,10 +121,24 @@ describe('Create Linode', () => { cy.url().should('endWith', `/linodes/${responsePayload['id']}`); }); + cy.wait('@getProfile').then((xhr) => { + username = xhr.response?.body.username; + }); + // TODO Confirm whether or not toast notification should appear here. cy.findByText('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( 'be.visible' ); + + // confirm that LISH Console via SSH section is correct + cy.contains('LISH Console via SSH') + .should('be.visible') + .closest('tr') + .within(() => { + cy.contains( + `ssh -t ${username}@lish-${linodeRegion.id}.linode.com ${linodeLabel}` + ).should('be.visible'); + }); }); }); }); From 60a108dc1c75a86b7af4e3b0851afd68fc2d312f Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 28 May 2024 10:30:09 -0400 Subject: [PATCH 027/163] feat: [M3-8023] - Refactor Image Upload and Add Tags (#10484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * save progress * save progress * save progress * save progress * upload flow works 🎉 * save progress * save progress * clean up * fix tsc errors * more progress * re-add redux and analytics * fix e2e tests by clicking submit button * add comments * fixes * add tags * Added changeset: Tags to image upload tab * re-add nicer error messages so UX does not get worse * fix small state bug * move react query logic * clean up imports * scroll errors into view and disable fields when upload is happening * all feedback from @mjac0bs except CLI modal changes * clean up the image upload CLI dialog * migrate to `Upload Using Command Line` * move helper text upon UX request --------- Co-authored-by: Banks Nussman --- .../pr-10484-added-1715972836354.md | 5 + .../core/images/machine-image-upload.spec.ts | 6 + .../CopyableTextField/CopyableTextField.tsx | 17 +- .../LinodeCLIModal/LinodeCLIModal.tsx | 85 --- .../ImageUploader/ImageUploader.stories.tsx | 6 +- .../ImageUploader/ImageUploader.test.tsx | 22 +- .../Uploaders/ImageUploader/ImageUploader.tsx | 444 +++---------- .../src/features/Images/ImageUpload.tsx | 597 ++++++++++-------- .../src/features/Images/ImageUpload.utils.ts | 41 ++ .../Images/ImageUploadCLIDialog.test.tsx | 58 ++ .../features/Images/ImageUploadCLIDialog.tsx | 62 ++ .../Images/ImagesCreate/ImageCreate.tsx | 37 +- packages/manager/src/queries/images.ts | 16 +- 13 files changed, 635 insertions(+), 761 deletions(-) create mode 100644 packages/manager/.changeset/pr-10484-added-1715972836354.md delete mode 100644 packages/manager/src/components/LinodeCLIModal/LinodeCLIModal.tsx create mode 100644 packages/manager/src/features/Images/ImageUpload.utils.ts create mode 100644 packages/manager/src/features/Images/ImageUploadCLIDialog.test.tsx create mode 100644 packages/manager/src/features/Images/ImageUploadCLIDialog.tsx diff --git a/packages/manager/.changeset/pr-10484-added-1715972836354.md b/packages/manager/.changeset/pr-10484-added-1715972836354.md new file mode 100644 index 00000000000..3786f7d08c2 --- /dev/null +++ b/packages/manager/.changeset/pr-10484-added-1715972836354.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Tags to image upload tab ([#10484](https://github.com/linode/manager/pull/10484)) diff --git a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts index 687a54abb80..f5d5e051b46 100644 --- a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts +++ b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts @@ -131,7 +131,13 @@ const uploadImage = (label: string) => { mimeType: 'application/x-gzip', }); }); + cy.intercept('POST', apiMatcher('images/upload')).as('imageUpload'); + + ui.button.findByAttribute('type', 'submit') + .should('be.enabled') + .should('be.visible') + .click(); }; authenticate(); diff --git a/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx b/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx index 20a294d8303..cb86b4b93b0 100644 --- a/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx +++ b/packages/manager/src/components/CopyableTextField/CopyableTextField.tsx @@ -1,16 +1,23 @@ import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; +import { + CopyTooltip, + CopyTooltipProps, +} from 'src/components/CopyTooltip/CopyTooltip'; import { TextField, TextFieldProps } from 'src/components/TextField'; interface CopyableTextFieldProps extends TextFieldProps { + /** + * Optional props that are passed to the underlying CopyTooltip component + */ + CopyTooltipProps?: Partial; className?: string; hideIcon?: boolean; } export const CopyableTextField = (props: CopyableTextFieldProps) => { - const { className, hideIcon, value, ...restProps } = props; + const { CopyTooltipProps, className, hideIcon, value, ...restProps } = props; return ( { {...restProps} InputProps={{ endAdornment: hideIcon ? undefined : ( - + ), }} className={`${className} copy removeDisabledStyles`} diff --git a/packages/manager/src/components/LinodeCLIModal/LinodeCLIModal.tsx b/packages/manager/src/components/LinodeCLIModal/LinodeCLIModal.tsx deleted file mode 100644 index 75e4110188a..00000000000 --- a/packages/manager/src/components/LinodeCLIModal/LinodeCLIModal.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { styled } from '@mui/material/styles'; -import * as React from 'react'; - -import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; -import { Dialog } from 'src/components/Dialog/Dialog'; -import { sendCLIClickEvent } from 'src/utilities/analytics/customEventAnalytics'; - -export interface ImageUploadSuccessDialogProps { - analyticsKey?: string; - command: string; - isOpen: boolean; - onClose: () => void; -} - -export const LinodeCLIModal = React.memo( - (props: ImageUploadSuccessDialogProps) => { - const { analyticsKey, command, isOpen, onClose } = props; - - return ( - - - {command}{' '} - sendCLIClickEvent(analyticsKey) : undefined - } - text={command} - /> - - - ); - } -); - -const StyledLinodeCLIModal = styled(Dialog, { - label: 'StyledLinodeCLIModal', -})(({ theme }) => ({ - '& [data-qa-copied]': { - zIndex: 2, - }, - padding: `${theme.spacing()} ${theme.spacing(2)}`, - width: '100%', -})); - -const StyledCommandDisplay = styled('div', { - label: 'StyledCommandDisplay', -})(({ theme }) => ({ - alignItems: 'center', - backgroundColor: theme.bg.main, - border: `1px solid ${theme.color.border2}`, - display: 'flex', - fontFamily: '"UbuntuMono", monospace, sans-serif', - fontSize: '0.875rem', - justifyContent: 'space-between', - lineHeight: 1, - padding: theme.spacing(), - position: 'relative', - whiteSpace: 'nowrap', - width: '100%', - wordBreak: 'break-all', -})); - -const StyledCLIText = styled('div', { - label: 'StyledCLIText', -})(() => ({ - height: '1rem', - overflowX: 'auto', - overflowY: 'hidden', // For Edge - paddingRight: 15, -})); - -const StyledCopyTooltip = styled(CopyTooltip, { - label: 'StyledCopyTooltip', -})(() => ({ - '& svg': { - height: '1em', - width: '1em', - }, - display: 'flex', -})); diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.stories.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.stories.tsx index dd01b6cae8b..35b7a42e3c2 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.stories.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.stories.tsx @@ -9,10 +9,8 @@ import type { Meta, StoryObj } from '@storybook/react'; */ export const _ImageUploader: StoryObj = { args: { - description: 'My Ubuntu Image for Production', - dropzoneDisabled: false, - label: 'file upload', - region: 'us-east-1', + isUploading: false, + progress: undefined, }, render: (args) => { return ; diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.test.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.test.tsx index 0d41c330e2d..ae64d58a2db 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.test.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.test.tsx @@ -5,22 +5,17 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { ImageUploader } from './ImageUploader'; const props = { - apiError: undefined, - dropzoneDisabled: false, - label: 'Upload files here', - onSuccess: vi.fn(), - region: 'us-east-1', - setCancelFn: vi.fn(), - setErrors: vi.fn(), + isUploading: false, + progress: undefined, }; describe('File Uploader', () => { it('properly renders the File Uploader', () => { const screen = renderWithTheme(); - const browseFiles = screen.getByTestId('upload-button'); + const browseFiles = screen.getByText('Browse Files').closest('button'); expect(browseFiles).toBeVisible(); - expect(browseFiles).toHaveAttribute('aria-disabled', 'false'); + expect(browseFiles).toBeEnabled(); const text = screen.getByText( 'You can browse your device to upload an image file or drop it here.' ); @@ -28,16 +23,15 @@ describe('File Uploader', () => { }); it('disables the dropzone', () => { - const screen = renderWithTheme( - - ); + const screen = renderWithTheme(); - const browseFiles = screen.getByTestId('upload-button'); + const browseFiles = screen.getByText('Browse Files').closest('button'); expect(browseFiles).toBeVisible(); + expect(browseFiles).toBeDisabled(); expect(browseFiles).toHaveAttribute('aria-disabled', 'true'); const text = screen.getByText( - 'To upload an image, complete the required fields.' + 'You can browse your device to upload an image file or drop it here.' ); expect(text).toBeVisible(); }); diff --git a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx index 7e1a5f23f6f..0392f8e5145 100644 --- a/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx +++ b/packages/manager/src/components/Uploaders/ImageUploader/ImageUploader.tsx @@ -1,397 +1,107 @@ -import { APIError } from '@linode/api-v4/lib/types'; -import { useSnackbar } from 'notistack'; +import { styled } from '@mui/material'; +import { Duration } from 'luxon'; import * as React from 'react'; -import { flushSync } from 'react-dom'; -import { FileRejection, useDropzone } from 'react-dropzone'; -import { useQueryClient } from '@tanstack/react-query'; -import { useDispatch } from 'react-redux'; -import { useHistory } from 'react-router-dom'; - -import { FileUpload } from 'src/components/Uploaders/FileUpload'; -import { - StyledCopy, - StyledDropZoneContentDiv, - StyledDropZoneDiv, - StyledFileUploadsDiv, - StyledUploadButton, -} from 'src/components/Uploaders/ImageUploader/ImageUploader.styles'; -import { onUploadProgressFactory } from 'src/components/Uploaders/ObjectUploader/ObjectUploader'; -import { - MAX_FILE_SIZE_IN_BYTES, - MAX_PARALLEL_UPLOADS, - curriedObjectUploaderReducer, - defaultState, - pathOrFileName, -} from 'src/components/Uploaders/reducer'; -import { uploadImageFile } from 'src/features/Images/requests'; -import { Dispatch } from 'src/hooks/types'; -import { useCurrentToken } from 'src/hooks/useAuthentication'; -import { imageQueries, useUploadImageMutation } from 'src/queries/images'; -import { redirectToLogin } from 'src/session'; -import { setPendingUpload } from 'src/store/pendingUpload'; -import { sendImageUploadEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { DropzoneProps, useDropzone } from 'react-dropzone'; + +import { BarPercent } from 'src/components/BarPercent'; +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { MAX_FILE_SIZE_IN_BYTES } from 'src/components/Uploaders/reducer'; import { readableBytes } from 'src/utilities/unitConversions'; -interface ImageUploaderProps { - /** - * An error to display if an upload error occurred. - */ - apiError: string | undefined; - /** - * The description of the upload that will be sent to the Linode API (used for Image uploads) - */ - description?: string; - /** - * Disables the ability to select image(s) to upload. - */ - dropzoneDisabled: boolean; - isCloudInit?: boolean; - /** - * The label of the upload that will be sent to the Linode API (used for Image uploads). - */ - label: string; - /** - * A function that is called when an upload is successful. - */ - onSuccess?: () => void; - /** - * The region ID to upload the image to. - */ - region: string; +import type { AxiosProgressEvent } from 'axios'; + +interface Props extends Partial { /** - * Allows you to set a cancel upload function in the parent component. + * Whether or not the upload is in progress. */ - setCancelFn: React.Dispatch void) | null>>; + isUploading: boolean; /** - * A function that allows you to set an error value in the parent component. + * The progress of the image upload. */ - setErrors: React.Dispatch>; + progress: AxiosProgressEvent | undefined; } /** * This component enables users to attach and upload images from a device. */ -export const ImageUploader = React.memo((props: ImageUploaderProps) => { - const { - apiError, - description, - dropzoneDisabled, - isCloudInit, - label, - onSuccess, - region, - setErrors, - } = props; - - const { enqueueSnackbar } = useSnackbar(); - const [uploadToURL, setUploadToURL] = React.useState(''); - const queryClient = useQueryClient(); - const { mutateAsync: uploadImage } = useUploadImageMutation({ - cloud_init: isCloudInit ? isCloudInit : undefined, - description: description ? description : undefined, - label, - region, - }); - - const history = useHistory(); - - // Keep track of the session token since we may need to grab the user a new - // one after a long upload (if their session has expired). - const currentToken = useCurrentToken(); - - const [state, dispatch] = React.useReducer( - curriedObjectUploaderReducer, - defaultState - ); - - const dispatchAction: Dispatch = useDispatch(); - - React.useEffect(() => { - const preventDefault = (e: any) => { - e.preventDefault(); - }; - - // This event listeners prevent the browser from opening files dropped on - // the screen, which was happening when the dropzone was disabled. - - // eslint-disable-next-line scanjs-rules/call_addEventListener - window.addEventListener('dragover', preventDefault); - // eslint-disable-next-line scanjs-rules/call_addEventListener - window.addEventListener('drop', preventDefault); - - return () => { - window.removeEventListener('dragover', preventDefault); - window.removeEventListener('drop', preventDefault); - }; - }, []); - - // This function is fired when files are dropped in the upload area. - const onDrop = (files: File[]) => { - const prefix = ''; - - // If an upload attempt failed previously, clear the dropzone. - if (state.numErrors > 0) { - dispatch({ type: 'CLEAR_UPLOAD_HISTORY' }); - } - - dispatch({ files, prefix, type: 'ENQUEUE' }); - }; - - // This function will be called when the user drops non-.gz files, more than one file at a time, or files that are over the max size. - const onDropRejected = (files: FileRejection[]) => { - const wrongFileType = !files[0].file.type.match(/gzip/gi); - const fileTypeErrorMessage = - 'Only raw disk images (.img) compressed using gzip (.gz) can be uploaded.'; - - const moreThanOneFile = files.length > 1; - const fileNumberErrorMessage = 'Only one file may be uploaded at a time.'; - - const fileSizeErrorMessage = `Max file size (${ - readableBytes(MAX_FILE_SIZE_IN_BYTES).formatted - }) exceeded`; - - if (wrongFileType) { - enqueueSnackbar(fileTypeErrorMessage, { - autoHideDuration: 10000, - variant: 'error', - }); - } else if (moreThanOneFile) { - enqueueSnackbar(fileNumberErrorMessage, { - autoHideDuration: 10000, - variant: 'error', - }); - } else { - enqueueSnackbar(fileSizeErrorMessage, { - autoHideDuration: 10000, - variant: 'error', - }); - } - }; - - const nextBatch = React.useMemo(() => { - if (state.numQueued === 0 || state.numInProgress > 0) { - return []; - } - - const queuedUploads = state.files.filter( - (upload) => upload.status === 'QUEUED' - ); - - return queuedUploads.slice(0, MAX_PARALLEL_UPLOADS - state.numInProgress); - }, [state.numQueued, state.numInProgress, state.files]); - - const uploadInProgressOrFinished = - state.numInProgress > 0 || state.numFinished > 0; - - // When `nextBatch` changes, upload the files. - React.useEffect(() => { - if (nextBatch.length === 0) { - return; - } - - nextBatch.forEach((fileUpload) => { - const { file } = fileUpload; - - const path = pathOrFileName(fileUpload.file); - - const onUploadProgress = onUploadProgressFactory(dispatch, path); - - const handleSuccess = () => { - if (onSuccess) { - onSuccess(); - } - - dispatch({ - data: { - percentComplete: 100, - status: 'FINISHED', - }, - filesToUpdate: [path], - type: 'UPDATE_FILES', - }); - - const successfulUploadMessage = `Image ${label} uploaded successfully. It is being processed and will be available shortly.`; - - enqueueSnackbar(successfulUploadMessage, { - autoHideDuration: 6000, - variant: 'success', - }); - - // React force a render so that `pendingUpload` is false when navigating away - // from the upload page. - flushSync(() => { - dispatchAction(setPendingUpload(false)); - }); - - recordImageAnalytics('success', file); - - // EDGE CASE: - // The upload has finished, but the user's token has expired. - // Show the toast, then redirect them to /images, passing them through - // Login to get a new token. - if (!currentToken) { - setTimeout(() => { - redirectToLogin('/images'); - }, 3000); - } else { - queryClient.invalidateQueries(imageQueries.paginated._def); - queryClient.invalidateQueries(imageQueries.all._def); - history.push('/images'); - } - }; - - const handleError = () => { - dispatch({ - data: { - status: 'ERROR', - }, - filesToUpdate: [path], - type: 'UPDATE_FILES', - }); - - dispatchAction(setPendingUpload(false)); - }; - - if (!uploadToURL) { - uploadImage() - .then((response) => { - setUploadToURL(response.upload_to); - - // Let the entire app know that there's a pending upload via Redux. - // High-level components like AuthenticationWrapper need to know - // this, so the user isn't redirected to Login if the token expires. - dispatchAction(setPendingUpload(true)); - - dispatch({ - data: { status: 'IN_PROGRESS' }, - filesToUpdate: [pathOrFileName(fileUpload.file)], - type: 'UPDATE_FILES', - }); - - recordImageAnalytics('start', file); - - const { cancel, request } = uploadImageFile( - response.upload_to, - file, - onUploadProgress - ); - - // The parent might need to cancel this upload (e.g. if the user - // navigates away from the page). - props.setCancelFn(() => () => cancel()); - - request() - .then(() => handleSuccess()) - .catch(() => handleError()); - }) - .catch((e) => { - dispatch({ type: 'CLEAR_UPLOAD_HISTORY' }); - setErrors(e); - }); - } else { - recordImageAnalytics('start', file); - - // Overwrite any file that was previously uploaded to the upload_to URL. - const { cancel, request } = uploadImageFile( - uploadToURL, - file, - onUploadProgress - ); - - props.setCancelFn(cancel); - - request() - .then(() => handleSuccess()) - .catch(() => { - handleError(); - recordImageAnalytics('fail', file); - dispatch({ type: 'CLEAR_UPLOAD_HISTORY' }); - }); - } - }); - }, [nextBatch]); - +export const ImageUploader = React.memo((props: Props) => { + const { isUploading, progress, ...dropzoneProps } = props; const { acceptedFiles, getInputProps, getRootProps, - isDragAccept, isDragActive, - isDragReject, - open, } = useDropzone({ accept: ['application/x-gzip', 'application/gzip'], // Uploaded files must be compressed using gzip. - disabled: dropzoneDisabled || uploadInProgressOrFinished, // disabled when dropzoneDisabled === true, an upload is in progress, or if an upload finished. maxFiles: 1, maxSize: MAX_FILE_SIZE_IN_BYTES, - noClick: true, - noKeyboard: true, - onDrop, - onDropRejected, + ...dropzoneProps, + disabled: dropzoneProps.disabled || isUploading, }); - const hideDropzoneBrowseBtn = - (isDragAccept || acceptedFiles.length > 0) && !apiError; // Checking that there isn't an apiError set to prevent disappearance of button if image creation isn't available in a region at that moment, etc. - - // const UploadZoneActive = - // state.files.filter((upload) => upload.status !== 'QUEUED').length !== 0; - - const uploadZoneActive = state.files.length !== 0; - - const placeholder = dropzoneDisabled - ? 'To upload an image, complete the required fields.' - : 'You can browse your device to upload an image file or drop it here.'; - return ( - - - - {state.files.map((upload, idx) => { - const fileName = upload.file.name; - return ( - - ); - })} - - - {!uploadZoneActive && ( - {placeholder} + + + + {acceptedFiles.length === 0 && ( + + You can browse your device to upload an image file or drop it here. + )} - {!hideDropzoneBrowseBtn ? ( - ( + + {file.name} ({readableBytes(file.size, { base10: true }).formatted}) + + ))} + + {isUploading && ( + + + + + + + {readableBytes(progress?.rate ?? 0, { base10: true }).formatted}/s{' '} + + + {Duration.fromObject({ seconds: progress?.estimated }).toHuman({ + maximumFractionDigits: 0, + })}{' '} + remaining + + + + )} + {!isUploading && ( + + + + )} + ); }); -const recordImageAnalytics = ( - action: 'fail' | 'start' | 'success', - file: File -) => { - const readableFileSize = readableBytes(file.size).formatted; - sendImageUploadEvent(action, readableFileSize); -}; +const Dropzone = styled('div')<{ active: boolean }>(({ active, theme }) => ({ + borderColor: 'gray', + borderStyle: 'dashed', + borderWidth: 1, + display: 'flex', + flexDirection: 'column', + gap: 16, + justifyContent: 'center', + minHeight: 150, + padding: 16, + ...(active && { + backgroundColor: theme.palette.background.default, + borderColor: theme.palette.primary.main, + }), +})); diff --git a/packages/manager/src/features/Images/ImageUpload.tsx b/packages/manager/src/features/Images/ImageUpload.tsx index 6e5738f00c0..704a0bce61a 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImageUpload.tsx @@ -1,152 +1,163 @@ -import { APIError } from '@linode/api-v4/lib/types'; -import { Theme } from '@mui/material/styles'; -import * as React from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useSnackbar } from 'notistack'; +import React, { useState } from 'react'; +import { flushSync } from 'react-dom'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; +import { useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom'; -import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Box } from 'src/components/Box'; +import { Button } from 'src/components/Button/Button'; import { Checkbox } from 'src/components/Checkbox'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Link } from 'src/components/Link'; -import { LinodeCLIModal } from 'src/components/LinodeCLIModal/LinodeCLIModal'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { Prompt } from 'src/components/Prompt/Prompt'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { Stack } from 'src/components/Stack'; +import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { ImageUploader } from 'src/components/Uploaders/ImageUploader/ImageUploader'; +import { MAX_FILE_SIZE_IN_BYTES } from 'src/components/Uploaders/reducer'; import { Dispatch } from 'src/hooks/types'; -import { useCurrentToken } from 'src/hooks/useAuthentication'; import { useFlags } from 'src/hooks/useFlags'; +import { usePendingUpload } from 'src/hooks/usePendingUpload'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { reportAgreementSigningError, useAccountAgreements, useMutateAccountAgreements, } from 'src/queries/account/agreements'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useUploadImageMutation } from 'src/queries/images'; +import { useProfile } from 'src/queries/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; -import { redirectToLogin } from 'src/session'; -import { ApplicationState } from 'src/store'; import { setPendingUpload } from 'src/store/pendingUpload'; -import { getErrorMap } from 'src/utilities/errorUtils'; import { getGDPRDetails } from 'src/utilities/formatRegion'; -import { wrapInQuotes } from 'src/utilities/stringUtils'; +import { readableBytes } from 'src/utilities/unitConversions'; import { EUAgreementCheckbox } from '../Account/Agreements/EUAgreementCheckbox'; +import { getRestrictedResourceText } from '../Account/utils'; +import { ImageUploadSchema, recordImageAnalytics } from './ImageUpload.utils'; +import { + ImageUploadFormData, + ImageUploadNavigationState, +} from './ImageUpload.utils'; +import { ImageUploadCLIDialog } from './ImageUploadCLIDialog'; +import { uploadImageFile } from './requests'; -const useStyles = makeStyles()((theme: Theme) => ({ - browseFilesButton: { - marginLeft: '1rem', - }, - cliModalButton: { - ...theme.applyLinkStyles, - fontFamily: theme.font.bold, - }, - cloudInitCheckboxWrapper: { - marginLeft: '3px', - marginTop: theme.spacing(2), - }, - container: { - '& .MuiFormHelperText-root': { - marginBottom: theme.spacing(2), - }, - minWidth: '100%', - paddingBottom: theme.spacing(), - paddingTop: theme.spacing(2), - }, - helperText: { - marginTop: theme.spacing(2), - [theme.breakpoints.down('sm')]: { - width: '100%', - }, - width: '90%', - }, -})); - -const cloudInitTooltipMessage = ( - - Only check this box if your Custom Image is compatible with cloud-init, or - has cloud-init installed, and the config has been changed to use our data - service.{' '} - - Learn how. - - -); +import type { AxiosError, AxiosProgressEvent } from 'axios'; -const imageSizeLimitsMessage = ( - - Image files must be raw disk images (.img) compressed using gzip (.gz). The - maximum file size is 5 GB (compressed) and maximum image size is 6 GB - (uncompressed). - -); +export const ImageUpload = () => { + const { location } = useHistory(); -export interface Props { - changeDescription: (e: React.ChangeEvent) => void; - changeIsCloudInit: () => void; - changeLabel: (e: React.ChangeEvent) => void; - description: string; - isCloudInit: boolean; - label: string; -} + const dispatch = useDispatch(); + const hasPendingUpload = usePendingUpload(); + const { push } = useHistory(); + const flags = useFlags(); -export const ImageUpload: React.FC = (props) => { - const { - changeDescription, - changeIsCloudInit, - changeLabel, - description, - isCloudInit, - label, - } = props; + const [uploadProgress, setUploadProgress] = useState(); + const cancelRef = React.useRef<(() => void) | null>(null); + const [hasSignedAgreement, setHasSignedAgreement] = useState(false); + const [linodeCLIModalOpen, setLinodeCLIModalOpen] = useState(false); const { data: profile } = useProfile(); - const { data: grants } = useGrants(); const { data: agreements } = useAccountAgreements(); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); + const { data: regions } = useRegionsQuery(); + const { mutateAsync: createImage } = useUploadImageMutation(); + const { enqueueSnackbar } = useSnackbar(); - const { classes } = useStyles(); - const regions = useRegionsQuery().data ?? []; - const dispatch: Dispatch = useDispatch(); - const { push } = useHistory(); - const flags = useFlags(); + const form = useForm({ + defaultValues: { + description: location.state?.imageDescription, + label: location.state?.imageLabel, + }, + mode: 'onBlur', + resolver: yupResolver(ImageUploadSchema), + }); - const [hasSignedAgreement, setHasSignedAgreement] = React.useState( - false - ); + const onSubmit = form.handleSubmit(async (values) => { + const { file, ...createPayload } = values; - const [region, setRegion] = React.useState(''); - const [errors, setErrors] = React.useState(); - const [linodeCLIModalOpen, setLinodeCLIModalOpen] = React.useState( - false - ); + try { + const { image, upload_to } = await createImage(createPayload); + + // Let the entire app know that there's a pending upload via Redux. + // High-level components like AuthenticationWrapper need to know + // this, so the user isn't redirected to Login if the token expires. + dispatch(setPendingUpload(true)); + + recordImageAnalytics('start', file); + + try { + const { cancel, request } = uploadImageFile( + upload_to, + file, + setUploadProgress + ); + + cancelRef.current = cancel; + + await request(); + + if (hasSignedAgreement) { + updateAccountAgreements({ + eu_model: true, + privacy_policy: true, + }).catch(reportAgreementSigningError); + } + + enqueueSnackbar( + `Image ${image.label} uploaded successfully. It is being processed and will be available shortly.`, + { variant: 'success' } + ); + + recordImageAnalytics('success', file); + + // Force a re-render so that `hasPendingUpload` is false when navigating away + // from the upload page. We need this to make the work as expected. + flushSync(() => { + dispatch(setPendingUpload(false)); + }); + + push('/images'); + } catch (error) { + // Handle an Axios error for the actual image upload + form.setError('root', { message: (error as AxiosError).message }); + // Update Redux to show we have no upload in progress + dispatch(setPendingUpload(false)); + recordImageAnalytics('fail', file); + } + } catch (errors) { + // Handle API errors from the POST /v4/images/upload + for (const error of errors) { + if (error.field) { + form.setError(error.field, { message: error.reason }); + } else { + window.scrollTo({ top: 0 }); + form.setError('root', { message: error.reason }); + } + } + // Update Redux to show we have no upload in progress + dispatch(setPendingUpload(false)); + } + }); + + const selectedRegionId = form.watch('region'); const { showGDPRCheckbox } = getGDPRDetails({ agreements, profile, regions, - selectedRegionId: region, + selectedRegionId, }); - // This holds a "cancel function" from the Axios instance that handles image - // uploads. Calling this function will cancel the HTTP request. - const [cancelFn, setCancelFn] = React.useState<(() => void) | null>(null); - - // Whether or not there is an upload pending. This is stored in Redux since - // high-level components like AuthenticationWrapper need to read it. - const pendingUpload = useSelector( - (state) => state.pendingUpload - ); - - // Keep track of the session token since we may need to grab the user a new - // one after a long upload (if their session has expired). - const currentToken = useCurrentToken(); - - const canCreateImage = - Boolean(!profile?.restricted) || Boolean(grants?.global?.add_images); + const isImageCreateRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_images', + }); // Called after a user confirms they want to navigate to another part of // Cloud during a pending upload. When we have refresh tokens this won't be @@ -154,55 +165,244 @@ export const ImageUpload: React.FC = (props) => { // will show the upload progress in the lower part of the screen. For now we // box the user on this page so we can handle token expiry (semi)-gracefully. const onConfirm = (nextLocation: string) => { - if (cancelFn) { - cancelFn(); + if (cancelRef.current) { + cancelRef.current(); } dispatch(setPendingUpload(false)); - // If the user's session has expired we need to send them to Login to get - // a new token. They will be redirected back to path they were trying to - // reach. - if (!currentToken) { - redirectToLogin(nextLocation); - } else { - push(nextLocation); - } - }; - - const onSuccess = () => { - if (hasSignedAgreement) { - updateAccountAgreements({ - eu_model: true, - privacy_policy: true, - }).catch(reportAgreementSigningError); - } + push(nextLocation); }; - const uploadingDisabled = - !label || - !region || - !canCreateImage || - (showGDPRCheckbox && !hasSignedAgreement); - - const errorMap = getErrorMap(['label', 'description', 'region'], errors); - - const cliLabel = formatForCLI(label, 'label'); - const cliDescription = formatForCLI(description, 'description'); - const cliRegion = formatForCLI(region, 'region'); - const linodeCLICommand = `linode-cli image-upload --label ${cliLabel} --description ${cliDescription} --region ${cliRegion} FILE`; - return ( - <> + + + + + + Image Details + + {form.formState.errors.root?.message && ( + + )} + {isImageCreateRestricted && ( + + )} + ( + + )} + control={form.control} + name="label" + /> + {flags.metadata && ( + + ( + + Only check this box if your Custom Image is compatible + with cloud-init, or has cloud-init installed, and the + config has been changed to use our data service.{' '} + + Learn how. + + + } + checked={field.value ?? false} + onChange={field.onChange} + text="This image is cloud-init compatible" + /> + )} + control={form.control} + name="cloud_init" + /> + + )} + ( + + )} + control={form.control} + name="region" + /> + ( + + field.onChange(items.map((item) => item.value)) + } + value={ + field.value?.map((tag) => ({ label: tag, value: tag })) ?? + [] + } + tagError={fieldState.error?.message} + /> + )} + control={form.control} + name="tags" + /> + ( + + )} + control={form.control} + name="description" + /> + {showGDPRCheckbox && ( + setHasSignedAgreement(e.target.checked)} + /> + )} + + + + Image Upload + + {form.formState.errors.file?.message && ( + + )} + + + Image files must be raw disk images (.img) compressed using gzip + (.gz). The maximum file size is 5 GB (compressed) and maximum + image size is 6 GB (uncompressed). + + + + Custom Images are billed at $0.10/GB per month based on the + uncompressed image size. + + ( + { + form.setError('file', {}); + field.onChange(files[0]); + }} + onDropRejected={(fileRejections) => { + let message = ''; + switch (fileRejections[0].errors[0].code) { + case 'file-invalid-type': + message = + 'Only raw disk images (.img) compressed using gzip (.gz) can be uploaded.'; + break; + case 'file-too-large': + message = `Max file size (${ + readableBytes(MAX_FILE_SIZE_IN_BYTES).formatted + }) exceeded`; + break; + default: + message = fileRejections[0].errors[0].message; + } + form.setError('file', { message }); + form.resetField('file', { keepError: true }); + }} + disabled={isImageCreateRestricted} + isUploading={form.formState.isSubmitting} + progress={uploadProgress} + /> + )} + control={form.control} + name="file" + /> + + + + + + + + setLinodeCLIModalOpen(false)} + /> {({ handleCancel, handleConfirm, isModalOpen }) => { return ( ( + actions={ = (props) => { onClick: handleCancel, }} /> - )} + } onClose={handleCancel} open={isModalOpen} title="Leave this page?" @@ -226,117 +426,6 @@ export const ImageUpload: React.FC = (props) => { ); }} - - - {errorMap.none ? : null} - {!canCreateImage ? ( - - ) : null} - -
- - - - {flags.metadata && ( -
- -
- )} - - {showGDPRCheckbox ? ( - setHasSignedAgreement(e.target.checked)} - /> - ) : null} - - {imageSizeLimitsMessage} - - - Custom Images are billed at $0.10/GB per month based on the - uncompressed image size. - - - - Or, upload an image using the{' '} - - . For more information, please see{' '} - - our guide on using the Linode CLI - - . - -
-
- setLinodeCLIModalOpen(false)} - /> - +
); }; - -export default ImageUpload; - -const formatForCLI = (value: string, fallback: string) => { - return value ? wrapInQuotes(value) : `[${fallback.toUpperCase()}]`; -}; diff --git a/packages/manager/src/features/Images/ImageUpload.utils.ts b/packages/manager/src/features/Images/ImageUpload.utils.ts new file mode 100644 index 00000000000..8e890f341f7 --- /dev/null +++ b/packages/manager/src/features/Images/ImageUpload.utils.ts @@ -0,0 +1,41 @@ +import { uploadImageSchema } from '@linode/validation'; +import { mixed } from 'yup'; + +import { sendImageUploadEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { readableBytes } from 'src/utilities/unitConversions'; + +import type { ImageUploadPayload } from '@linode/api-v4'; + +export const recordImageAnalytics = ( + action: 'fail' | 'start' | 'success', + file: File +) => { + const readableFileSize = readableBytes(file.size).formatted; + sendImageUploadEvent(action, readableFileSize); +}; + +/** + * We extend the image upload payload to contain the file + * so we can use react-hook-form to manage all of the form state. + */ +export interface ImageUploadFormData extends ImageUploadPayload { + file: File; +} + +/** + * We extend the image upload schema to contain the file + * so we can use react-hook-form to validate all of the + * form state at once. + */ +export const ImageUploadSchema = uploadImageSchema.shape({ + file: mixed().required('Image is required.'), +}); + +/** + * We use navigation state to pre-fill the upload form + * when the user "retries" an upload. + */ +export interface ImageUploadNavigationState { + imageDescription?: string; + imageLabel?: string; +} diff --git a/packages/manager/src/features/Images/ImageUploadCLIDialog.test.tsx b/packages/manager/src/features/Images/ImageUploadCLIDialog.test.tsx new file mode 100644 index 00000000000..ba282570ae2 --- /dev/null +++ b/packages/manager/src/features/Images/ImageUploadCLIDialog.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { ImageUploadCLIDialog } from './ImageUploadCLIDialog'; + +import type { ImageUploadFormData } from './ImageUpload.utils'; + +describe('ImageUploadCLIDialog', () => { + it('should render a title', () => { + const { getByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(getByText('Upload Image with the Linode CLI')).toBeVisible(); + }); + + it('should render nothing when isOpen is false', () => { + const { container } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should render a default CLI command with no form data', () => { + const { getByDisplayValue } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect( + getByDisplayValue( + 'linode-cli image-upload --label [LABEL] --description [DESCRIPTION] --region [REGION] FILE' + ) + ).toBeVisible(); + }); + + it('should render a CLI command based on form data', () => { + const { + getByDisplayValue, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + description: 'this is my cool image', + label: 'my-image', + region: 'us-east', + }, + }, + }); + + expect( + getByDisplayValue( + 'linode-cli image-upload --label "my-image" --description "this is my cool image" --region "us-east" FILE' + ) + ).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Images/ImageUploadCLIDialog.tsx b/packages/manager/src/features/Images/ImageUploadCLIDialog.tsx new file mode 100644 index 00000000000..898ec158279 --- /dev/null +++ b/packages/manager/src/features/Images/ImageUploadCLIDialog.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { CopyableTextField } from 'src/components/CopyableTextField/CopyableTextField'; +import { Dialog } from 'src/components/Dialog/Dialog'; +import { Link } from 'src/components/Link'; +import { Typography } from 'src/components/Typography'; +import { sendCLIClickEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { wrapInQuotes } from 'src/utilities/stringUtils'; + +import type { ImageUploadFormData } from './ImageUpload.utils'; + +interface ImageUploadSuccessDialogProps { + isOpen: boolean; + onClose: () => void; +} + +export const ImageUploadCLIDialog = (props: ImageUploadSuccessDialogProps) => { + const { isOpen, onClose } = props; + + const form = useFormContext(); + + const { description, label, region } = form.getValues(); + + const cliLabel = formatForCLI(label, 'label'); + const cliDescription = formatForCLI(description ?? '', 'description'); + const cliRegion = formatForCLI(region, 'region'); + + const command = `linode-cli image-upload --label ${cliLabel} --description ${cliDescription} --region ${cliRegion} FILE`; + + return ( + + sendCLIClickEvent('Image Upload'), + }} + expand + hideLabel + label="CLI Command" + noMarginTop + sx={{ fontFamily: 'UbuntuMono, monospace, sans-serif' }} + value={command} + /> + + For more information, please see{' '} + + our guide on using the Linode CLI + + . + + + ); +}; + +const formatForCLI = (value: string, fallback: string) => { + return value ? wrapInQuotes(value) : `[${fallback.toUpperCase()}]`; +}; diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx index c954d558318..fafc8614c04 100644 --- a/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx @@ -1,38 +1,22 @@ import * as React from 'react'; -import { useHistory, useRouteMatch } from 'react-router-dom'; +import { useRouteMatch } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { NavTab, NavTabs } from 'src/components/NavTabs/NavTabs'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; +const ImageUpload = React.lazy(() => + import('../ImageUpload').then((module) => ({ default: module.ImageUpload })) +); + const CreateImageTab = React.lazy(() => import('./CreateImageTab').then((module) => ({ default: module.CreateImageTab, })) ); -const ImageUpload = React.lazy(() => import('../ImageUpload')); export const ImageCreate = () => { const { url } = useRouteMatch(); - const { location } = useHistory(); - - const [label, setLabel] = React.useState( - location?.state ? location.state.imageLabel : '' - ); - const [description, setDescription] = React.useState( - location?.state ? location.state.imageDescription : '' - ); - const [isCloudInit, setIsCloudInit] = React.useState(false); - - const handleSetLabel = (e: React.ChangeEvent) => { - const value = e.target.value; - setLabel(value); - }; - - const handleSetDescription = (e: React.ChangeEvent) => { - const value = e.target.value; - setDescription(value); - }; const tabs: NavTab[] = [ { @@ -41,16 +25,7 @@ export const ImageCreate = () => { title: 'Capture Image', }, { - render: ( - setIsCloudInit(!isCloudInit)} - changeLabel={handleSetLabel} - description={description} - isCloudInit={isCloudInit} - label={label} - /> - ), + render: , routeName: `${url}/upload`, title: 'Upload Image', }, diff --git a/packages/manager/src/queries/images.ts b/packages/manager/src/queries/images.ts index 0fc3ea99859..fc5a7c114cb 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/manager/src/queries/images.ts @@ -119,10 +119,20 @@ export const useAllImagesQuery = ( enabled, }); -export const useUploadImageMutation = (payload: ImageUploadPayload) => - useMutation({ - mutationFn: () => uploadImage(payload), +export const useUploadImageMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: uploadImage, + onSuccess(data) { + queryClient.invalidateQueries(imageQueries.paginated._def); + queryClient.invalidateQueries(imageQueries.all._def); + queryClient.setQueryData( + imageQueries.image(data.image.id).queryKey, + data.image + ); + }, }); +}; export const imageEventsHandler = ({ event, From 49b9a01423f44043d2461d0979e9a2ffd31eb003 Mon Sep 17 00:00:00 2001 From: sujai-git <136849150+sujai-git@users.noreply.github.com> Date: Tue, 28 May 2024 12:02:11 -0400 Subject: [PATCH 028/163] test: [DBAAS1-661] - Test automation for database resize (#10461) * Added test for vertical scaling * Added changes for vertical scaling coverage * Added pricing to plans * Added pricing to plans * Added changes to resize test * switch plan to resize permutation added * Fixed intercepts/databases.ts * Added changeset: Test automation for database resize * updated test to fill out the type-to-confirm field * Delete packages/manager/.changeset/pr-10461-tests-1715634181887.md Removed extra changes file. * Delete packages/manager/.changeset/pr-10461-tests-1715634582627.md Removed extra changeset file. * Delete packages/manager/.changeset/pr-10461-tests-1715634086273.md Removed extra changeset file. --- packages/api-v4/src/databases/types.ts | 4 +- .../pr-10461-tests-1715633900017.md | 5 + .../core/databases/resize-database.spec.ts | 368 ++++++++++++++++++ .../notificationsAndEvents/events.spec.ts | 1 + .../cypress/support/constants/databases.ts | 294 +++++++++++++- .../cypress/support/intercepts/databases.ts | 36 ++ .../components/TabbedPanel/TabbedPanel.tsx | 5 +- packages/manager/src/factories/databases.ts | 10 +- 8 files changed, 715 insertions(+), 8 deletions(-) create mode 100644 packages/manager/.changeset/pr-10461-tests-1715633900017.md create mode 100644 packages/manager/cypress/e2e/core/databases/resize-database.spec.ts diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 1e079d38c8d..3220c35f842 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -29,14 +29,14 @@ export interface DatabaseEngine { export type DatabaseStatus = | 'provisioning' + | 'resizing' | 'active' | 'suspending' | 'suspended' | 'resuming' | 'restoring' | 'failed' - | 'degraded' - | 'resizing'; + | 'degraded'; export type DatabaseBackupType = 'snapshot' | 'auto'; diff --git a/packages/manager/.changeset/pr-10461-tests-1715633900017.md b/packages/manager/.changeset/pr-10461-tests-1715633900017.md new file mode 100644 index 00000000000..c13c2755933 --- /dev/null +++ b/packages/manager/.changeset/pr-10461-tests-1715633900017.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Added test automation for database resize feature. ([#10461](https://github.com/linode/manager/pull/10461)) diff --git a/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts b/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts new file mode 100644 index 00000000000..1dab1c0e0ad --- /dev/null +++ b/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts @@ -0,0 +1,368 @@ +/** + * @file DBaaS integration tests for resize operations. + */ + +import { randomNumber, randomIp, randomString } from 'support/util/random'; +import { databaseFactory, possibleStatuses } from 'src/factories/databases'; +import { ui } from 'support/ui'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + mockGetDatabase, + mockGetDatabaseCredentials, + mockGetDatabaseTypes, + mockResize, + mockResizeProvisioningDatabase, +} from 'support/intercepts/databases'; +import { + databaseClusterConfiguration, + databaseConfigurationsResize, + mockDatabaseNodeTypes, +} from 'support/constants/databases'; +import { accountFactory } from '@src/factories'; + +/** + * Resizes a current database cluster to a larger plan size. + * + * This requires that the 'Resize' tab is currently active. No + * assertion is made on the result of the access control update attempt. + * + * @param initialLabel - Database label to resize. + */ + +const resizeDatabase = (initialLabel: string) => { + ui.button + .findByTitle('Resize Database Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + ui.dialog + .findByTitle(`Resize Database Cluster ${initialLabel}?`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Cluster Name').click().type(initialLabel); + ui.buttonGroup + .findButtonByTitle('Resize Cluster') + .should('be.visible') + .click(); + }); +}; + +describe('Resizing existing clusters', () => { + databaseConfigurationsResize.forEach( + (configuration: databaseClusterConfiguration) => { + describe(`Resizes a ${configuration.linodeType} ${configuration.engine} v${configuration.version}.x ${configuration.clusterSize}-node cluster`, () => { + /* + * - Tests active database resize UI flows using mocked data. + * - Confirms that users can resize an existing database. + * - Confirms that users can not downsize and smaller plans are disabled. + * - Confirms that larger size plans are enabled to select for resizing and summary section displays pricing details for the selected plan. + */ + it('Can resize active database clusters', () => { + const initialLabel = configuration.label; + const allowedIp = randomIp(); + const initialPassword = randomString(16); + const database = databaseFactory.build({ + id: randomNumber(1, 1000), + type: configuration.linodeType, + label: initialLabel, + region: configuration.region.id, + engine: configuration.dbType, + cluster_size: 3, + status: 'active', + allow_list: [allowedIp], + }); + + // Mock account to ensure 'Managed Databases' capability. + const databaseType = mockDatabaseNodeTypes.find( + (nodeType) => nodeType.id === database.type + ); + if (!databaseType) { + throw new Error(`Unknown database type ${database.type}`); + } + mockGetAccount(accountFactory.build()).as('getAccount'); + mockGetDatabase(database).as('getDatabase'); + mockGetDatabaseTypes(mockDatabaseNodeTypes).as('getDatabaseTypes'); + mockGetDatabaseCredentials( + database.id, + database.engine, + initialPassword + ).as('getCredentials'); + + cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); + cy.wait(['@getAccount', '@getDatabase', '@getDatabaseTypes']); + + cy.get('[data-reach-tab-list]').within(() => { + cy.findByText('Resize').should('be.visible').click(); + }); + ui.button + .findByTitle('Resize Database Cluster') + .should('be.visible') + .should('be.disabled'); + + let nodeTypeClass = ''; + + ['Dedicated CPU', 'Shared CPU'].forEach((tabTitle) => { + // Click on the tab we want. + ui.button.findByTitle(tabTitle).should('be.visible').click(); + + if (tabTitle == 'Dedicated CPU') { + nodeTypeClass = 'dedicated'; + } else { + nodeTypeClass = 'standard'; + } + // Find the smaller plans name using `nodeType` and check radio button is disabled to select + mockDatabaseNodeTypes + .filter( + (nodeType) => + nodeType.class === nodeTypeClass && + nodeType.memory < databaseType.memory + ) + .forEach((nodeType) => { + cy.get('[aria-label="List of Linode Plans"]') + .should('be.visible') + .each(() => { + cy.contains(nodeType.label).should('be.visible'); + cy.get(`[id="${nodeType.id}"]`).should('be.disabled'); + }); + }); + + // Find the larger plans name using `nodeType` and check radio button is enabled to select + mockDatabaseNodeTypes + .filter( + (nodeType) => + nodeType.class === nodeTypeClass && + nodeType.memory > databaseType.memory + ) + .forEach((nodeType) => { + cy.get('[aria-label="List of Linode Plans"]') + .should('be.visible') + .each(() => { + cy.get(`[id="${nodeType.id}"]`) + .should('be.enabled') + .click(); + }); + const desiredPlanPrice = nodeType.engines[ + configuration.dbType + ].find((dbClusterSizeObj: { quantity: number }) => { + return dbClusterSizeObj.quantity === database.cluster_size; + })?.price; + if (!desiredPlanPrice) { + throw new Error('Unable to find mock plan type'); + } + cy.get('[data-testid="summary"]').within(() => { + cy.contains(`${nodeType.label}`).should('be.visible'); + cy.contains(`$${desiredPlanPrice.monthly}/month`).should( + 'be.visible' + ); + cy.contains(`$${desiredPlanPrice.hourly}/hour`).should( + 'be.visible' + ); + }); + }); + }); + // Find the current plan name using `nodeType` and check if it has current tag displaying in UI and radio button disabled, + if (configuration.linodeType.includes('dedicated')) { + nodeTypeClass = 'dedicated'; + ui.button.findByTitle('Dedicated CPU').should('be.visible').click(); + } else { + nodeTypeClass = 'standard'; + ui.button.findByTitle('Shared CPU').should('be.visible').click(); + } + mockDatabaseNodeTypes + .filter( + (nodeType) => + // nodeType.class === nodeTypeClass && + nodeType.id === database.type + ) + .forEach((nodeType) => { + cy.get('[aria-label="List of Linode Plans"]') + .should('be.visible') + .each(() => { + cy.get(`[data-qa-current-plan]`) + .parent() + .should('contain', nodeType.label) + .should('contain', 'Current Plan'); + }); + }); + + const largePlan = mockDatabaseNodeTypes.filter( + (nodeType) => + nodeType.class === nodeTypeClass && + nodeType.memory > databaseType.memory + ); + if (!databaseType) { + throw new Error(`Unknown database type ${database.type}`); + } + cy.get(`[id="${largePlan[0].id}"]`).click(); + + mockResize(database.id, database.engine, { + ...database, + type: 'g6-standard-32', + }).as('scaleUpDatabase'); + resizeDatabase(initialLabel); + cy.wait('@scaleUpDatabase'); + }); + + /* + * - Tests active database resize UI flows using mocked data. + * - Confirms that users can resize an existing database from dedicated to shared. + * - Confirms that users can resize an existing database from shared to dedicated. + */ + it(`Can resize active database clusters from ${configuration.linodeType} type and switch plan type`, () => { + const initialLabel = configuration.label; + const allowedIp = randomIp(); + const initialPassword = randomString(16); + const database = databaseFactory.build({ + id: randomNumber(1, 1000), + type: configuration.linodeType, + label: initialLabel, + region: configuration.region.id, + engine: configuration.dbType, + cluster_size: 3, + status: 'active', + allow_list: [allowedIp], + }); + + // Mock account to ensure 'Managed Databases' capability. + const databaseType = mockDatabaseNodeTypes.find( + (nodeType) => nodeType.id === database.type + ); + if (!databaseType) { + throw new Error(`Unknown database type ${database.type}`); + } + mockGetAccount(accountFactory.build()).as('getAccount'); + mockGetDatabase(database).as('getDatabase'); + mockGetDatabaseTypes(mockDatabaseNodeTypes).as('getDatabaseTypes'); + mockGetDatabaseCredentials( + database.id, + database.engine, + initialPassword + ).as('getCredentials'); + + cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); + cy.wait(['@getAccount', '@getDatabase', '@getDatabaseTypes']); + + cy.get('[data-reach-tab-list]').within(() => { + cy.findByText('Resize').should('be.visible').click(); + }); + ui.button + .findByTitle('Resize Database Cluster') + .should('be.visible') + .should('be.disabled'); + + let nodeTypeClass = ''; + // Find the current plan name using `nodeType` and switch to another tab for selecting plan. + if (configuration.linodeType.includes('dedicated')) { + nodeTypeClass = 'dedicated'; + ui.button.findByTitle('Shared CPU').should('be.visible').click(); + } else { + nodeTypeClass = 'standard'; + ui.button.findByTitle('Dedicated CPU').should('be.visible').click(); + } + + const largePlan = mockDatabaseNodeTypes.filter( + (nodeType) => + nodeType.class != nodeTypeClass && + nodeType.memory > databaseType.memory + ); + if (!databaseType) { + throw new Error(`Unknown database type ${database.type}`); + } + cy.get(`[id="${largePlan[0].id}"]`).click(); + + mockResize(database.id, database.engine, { + ...database, + type: 'g6-standard-32', + }).as('scaleUpDatabase'); + resizeDatabase(initialLabel); + cy.wait('@scaleUpDatabase'); + }); + + /* + * - Tests resizing database using mocked data. + * - Confirms that users cannot resize database for provisioning DBs. + * - Confirms that users cannot resize database for restoring DBs. + * - Confirms that users cannot resize database for failed DBs. + * - Confirms that users cannot resize database for degraded DBs + */ + it('Cannot resize database clusters while they are not in active state', () => { + // const databaseStatus = ["provisioning", 'failed', 'restoring']; + possibleStatuses.forEach((dbstatus) => { + if (dbstatus != 'active') { + const initialLabel = configuration.label; + const allowedIp = randomIp(); + const database = databaseFactory.build({ + id: randomNumber(1, 1000), + type: configuration.linodeType, + label: initialLabel, + region: configuration.region.id, + engine: configuration.dbType, + cluster_size: 3, + status: dbstatus, + allow_list: [allowedIp], + hosts: { + primary: undefined, + secondary: undefined, + }, + }); + + const errorMessage = `Your database is ${dbstatus}; please wait until it becomes active to perform this operation.`; + + mockGetAccount(accountFactory.build()).as('getAccount'); + mockGetDatabase(database).as('getDatabase'); + mockGetDatabaseTypes(mockDatabaseNodeTypes).as( + 'getDatabaseTypes' + ); + + cy.visitWithLogin(`/databases/${database.engine}/${database.id}`); + cy.wait(['@getAccount', '@getDatabaseTypes', '@getDatabase']); + + mockResize(database.id, database.engine, { + ...database, + type: 'g6-standard-32', + }).as('resizeDatabase'); + + mockResizeProvisioningDatabase( + database.id, + database.engine, + errorMessage + ).as('resizeDatabase'); + + cy.get('[data-reach-tab-list]').within(() => { + cy.findByText('Resize').should('be.visible').click(); + }); + const databaseType = mockDatabaseNodeTypes.find( + (nodeType) => nodeType.id === database.type + ); + if (!databaseType) { + throw new Error(`Unknown database type ${database.type}`); + } + let nodeTypeClass = ''; + if (configuration.linodeType.includes('standard')) { + nodeTypeClass = 'standard'; + } else { + nodeTypeClass = 'dedicated'; + } + const largePlan = mockDatabaseNodeTypes.filter( + (nodeType) => + nodeType.class === nodeTypeClass && + nodeType.memory > databaseType.memory + ); + if (!databaseType) { + throw new Error(`Unknown database type ${database.type}`); + } + cy.get(`[id="${largePlan[0].id}"]`).click(); + resizeDatabase(initialLabel); + cy.wait('@resizeDatabase'); + cy.findByText(errorMessage).should('be.visible'); + cy.get('[data-qa-cancel="true"]') + .should('be.visible') + .should('be.enabled') + .click(); + } + }); + }); + }); + } + ); +}); diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts index c2bfc924014..e7020b6911d 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts @@ -15,6 +15,7 @@ const eventActions: RecPartial[] = [ 'disk_duplicate', 'disk_resize', 'disk_update', + 'database_resize', 'database_low_disk_space', 'entity_transfer_accept', 'entity_transfer_cancel', diff --git a/packages/manager/cypress/support/constants/databases.ts b/packages/manager/cypress/support/constants/databases.ts index 4957b0cec4a..e463f331750 100644 --- a/packages/manager/cypress/support/constants/databases.ts +++ b/packages/manager/cypress/support/constants/databases.ts @@ -46,10 +46,281 @@ export const mockDatabaseNodeTypes: DatabaseType[] = [ databaseTypeFactory.build({ class: 'nanode', id: 'g6-nanode-1', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 0.0225, + monthly: 15, + }, + quantity: 3, + }, + ], + }, + memory: 1024, + disk: 25600, + vcpus: 1, + label: 'Nanode 1 GB', + }), + databaseTypeFactory.build({ + class: 'dedicated', + id: 'g6-dedicated-2', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 0.2925, + monthly: 195, + }, + quantity: 3, + }, + ], + }, + memory: 4096, + disk: 81920, + vcpus: 2, + label: 'Dedicated 4 GB', + }), + databaseTypeFactory.build({ + class: 'dedicated', + id: 'g6-dedicated-4', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 0.585, + monthly: 390.0, + }, + quantity: 3, + }, + ], + }, + memory: 8192, + disk: 163840, + vcpus: 4, + label: 'Dedicated 8 GB', + }), + databaseTypeFactory.build({ + class: 'dedicated', + id: 'g6-dedicated-8', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 1.17, + monthly: 780, + }, + quantity: 3, + }, + ], + }, + memory: 16384, + disk: 327680, + vcpus: 6, + label: 'Dedicated 16 GB', }), databaseTypeFactory.build({ class: 'dedicated', id: 'g6-dedicated-16', + engines: { + mysql: [ + { + price: { + hourly: 2.34, + monthly: 1560.0, + }, + quantity: 3, + }, + ], + }, + memory: 32768, + disk: 655360, + vcpus: 8, + label: 'Dedicated 32 GB', + }), + databaseTypeFactory.build({ + class: 'dedicated', + id: 'g6-dedicated-32', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 4.68, + monthly: 3120.0, + }, + quantity: 3, + }, + ], + }, + memory: 65536, + disk: 1310720, + vcpus: 16, + label: 'Dedicated 64 GB', + }), + databaseTypeFactory.build({ + class: 'dedicated', + id: 'g6-dedicated-48', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 7.02, + monthly: 4680, + }, + quantity: 3, + }, + ], + }, + memory: 98304, + disk: 1966080, + vcpus: 20, + label: 'Dedicated 96 GB', + }), + databaseTypeFactory.build({ + class: 'standard', + id: 'g6-standard-1', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 0.09, + monthly: 60, + }, + quantity: 3, + }, + ], + }, + disk: 51200, + label: 'Linode 2 GB', + memory: 2048, + vcpus: 1, + }), + databaseTypeFactory.build({ + class: 'standard', + id: 'g6-standard-2', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 0.18, + monthly: 120, + }, + quantity: 3, + }, + ], + }, + disk: 81920, + label: 'Linode 4 GB', + memory: 4096, + vcpus: 2, + }), + databaseTypeFactory.build({ + class: 'standard', + id: 'g6-standard-4', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 0.36, + monthly: 240, + }, + quantity: 3, + }, + ], + }, + disk: 163840, + label: 'Linode 8 GB', + memory: 8192, + vcpus: 4, + }), + databaseTypeFactory.build({ + class: 'standard', + id: 'g6-standard-6', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 0.84, + monthly: 560.0, + }, + quantity: 3, + }, + ], + }, + disk: 327680, + label: 'Linode 16 GB', + memory: 16384, + vcpus: 6, + }), + databaseTypeFactory.build({ + class: 'standard', + id: 'g6-standard-8', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 1.68, + monthly: 1120.0, + }, + quantity: 3, + }, + ], + }, + disk: 655360, + label: 'Linode 32 GB', + memory: 32768, + vcpus: 8, + }), + databaseTypeFactory.build({ + class: 'standard', + id: 'g6-standard-16', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 3.336, + monthly: 2224.0, + }, + quantity: 3, + }, + ], + }, + disk: 1310720, + label: 'Linode 64 GB', + memory: 65536, + vcpus: 16, + }), + databaseTypeFactory.build({ + class: 'standard', + id: 'g6-standard-20', + engines: { + mysql: [ + { + price: { + // (Insert your desired price here) + hourly: 5.04, + monthly: 3360.0, + }, + quantity: 3, + }, + ], + }, + disk: 1966080, + label: 'Linode 96 GB', + memory: 98304, + vcpus: 20, }), ]; @@ -69,7 +340,7 @@ export const databaseConfigurations: databaseClusterConfiguration[] = [ dbType: 'mysql', engine: 'MySQL', label: randomLabel(), - linodeType: 'g6-dedicated-16', + linodeType: 'g6-dedicated-2', region: chooseRegion({ capabilities: ['Managed Databases'] }), version: '5', }, @@ -93,3 +364,24 @@ export const databaseConfigurations: databaseClusterConfiguration[] = [ version: '13', }, ]; + +export const databaseConfigurationsResize: databaseClusterConfiguration[] = [ + { + clusterSize: 3, + dbType: 'mysql', + engine: 'MySQL', + label: randomLabel(), + linodeType: 'g6-standard-6', + region: chooseRegion({ capabilities: ['Managed Databases'] }), + version: '8', + }, + { + clusterSize: 3, + dbType: 'mysql', + engine: 'MySQL', + label: randomLabel(), + linodeType: 'g6-dedicated-16', + region: chooseRegion({ capabilities: ['Managed Databases'] }), + version: '5', + }, +]; diff --git a/packages/manager/cypress/support/intercepts/databases.ts b/packages/manager/cypress/support/intercepts/databases.ts index 4c9ce05951a..72cff995b53 100644 --- a/packages/manager/cypress/support/intercepts/databases.ts +++ b/packages/manager/cypress/support/intercepts/databases.ts @@ -126,6 +126,42 @@ export const mockUpdateDatabase = ( ); }; +/** + * Intercepts POST request to reset an active database's password and mocks response. + * + * @param id - Database ID. + * @param engine - Database engine type. + * + * @returns Cypress chainable. + */ + +export const mockResize = ( + id: number, + engine: string, + responseData: any = {} +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`databases/${engine}/instances/${id}`), + responseData + ); +}; + +export const mockResizeProvisioningDatabase = ( + id: number, + engine: string, + responseErrorMessage?: string | undefined +): Cypress.Chainable => { + const error = makeErrorResponse( + responseErrorMessage || defaultErrorMessageProvisioning + ); + return cy.intercept( + 'PUT', + apiMatcher(`databases/${engine}/instances/${id}`), + error + ); +}; + /** * Intercepts PUT request to update a provisioning database and mocks response. * diff --git a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx index 5860f17cebc..c748d7958ee 100644 --- a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx +++ b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx @@ -117,7 +117,10 @@ const TabbedPanel = React.memo((props: TabbedPanelProps) => { {tabs.map((tab, idx) => ( - + {tab.render(rest.children)} ))} diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index 856d20ff021..4ca68e7b504 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -14,11 +14,13 @@ import { v4 } from 'uuid'; import { pickRandom, randomDate } from 'src/utilities/random'; // These are not all of the possible statuses, but these are some common ones. -const possibleStatuses: DatabaseStatus[] = [ +export const possibleStatuses: DatabaseStatus[] = [ 'provisioning', 'active', 'failed', 'degraded', + 'restoring', + 'resizing', ]; export const possibleMySQLReplicationTypes: MySQLReplicationType[] = [ @@ -37,7 +39,6 @@ export const IPv4List = ['192.0.2.1', '196.0.0.0', '198.0.0.2']; export const databaseTypeFactory = Factory.Sync.makeFactory({ class: 'standard', - disk: 20480, engines: { mongodb: [ { @@ -133,9 +134,10 @@ export const databaseTypeFactory = Factory.Sync.makeFactory({ ], }, id: Factory.each((i) => `g6-standard-${i}`), + disk: Factory.each((i) => i * 20480), label: Factory.each((i) => `Linode ${i} GB`), - memory: 2048, - vcpus: 2, + memory: Factory.each((i) => i * 2048), + vcpus: Factory.each((i) => i * 2), }); export const databaseInstanceFactory = Factory.Sync.makeFactory( From 5a820405f1343616542f684d2cef42c4485ea8a9 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 29 May 2024 09:33:26 -0400 Subject: [PATCH 029/163] fix: Cypress test creating an Image without a `cy-test` prefixed label (#10510) Co-authored-by: Banks Nussman --- .../cypress/e2e/core/stackscripts/create-stackscripts.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index 926843d8496..1edfb9845ca 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -134,6 +134,7 @@ const createLinodeAndImage = async () => { const image = await createImage({ disk_id: diskId, + label: randomLabel(), }); await pollImageStatus( From 6f013a596a827fca331727df9aaff35571582e6e Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Wed, 29 May 2024 09:55:30 -0500 Subject: [PATCH 030/163] fix: [M3-8164] - regions length check (#10519) * fix: [M3-8164] - regions length check * Added changeset: fix regions length check in HostNameTableCell * PR feedback: @bnussman and @DevDW --- .../.changeset/pr-10519-upcoming-features-1716918242639.md | 5 +++++ .../AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-10519-upcoming-features-1716918242639.md diff --git a/packages/manager/.changeset/pr-10519-upcoming-features-1716918242639.md b/packages/manager/.changeset/pr-10519-upcoming-features-1716918242639.md new file mode 100644 index 00000000000..578cd372ff5 --- /dev/null +++ b/packages/manager/.changeset/pr-10519-upcoming-features-1716918242639.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +fix regions length check in HostNameTableCell ([#10519](https://github.com/linode/manager/pull/10519)) diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx index 4b156face0c..f9960f25f7a 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx @@ -28,8 +28,8 @@ export const HostNameTableCell = ({ const { regions } = storageKeyData; - if (!regionsLookup || !regionsData || !regions) { - return ; + if (!regionsLookup || !regionsData || regions.length === 0) { + return None; } return ( From c849cfe028937247ec8e41a44bfe703c47a5d7dd Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 29 May 2024 11:22:55 -0400 Subject: [PATCH 031/163] refactor: [M3-8128] - Query Key Factory for Support Tickets (#10496) * add query key factory * Added changeset: Query Key Factory for Support Tickets * add create mutation * update cypress test to account for better cache behavior --------- Co-authored-by: Banks Nussman --- .../pr-10496-tech-stories-1716315886330.md | 5 + .../open-support-ticket.spec.ts | 2 - .../SupportTickets/SupportTicketDialog.tsx | 9 +- packages/manager/src/queries/support.ts | 116 ++++++++++++++---- 4 files changed, 98 insertions(+), 34 deletions(-) create mode 100644 packages/manager/.changeset/pr-10496-tech-stories-1716315886330.md diff --git a/packages/manager/.changeset/pr-10496-tech-stories-1716315886330.md b/packages/manager/.changeset/pr-10496-tech-stories-1716315886330.md new file mode 100644 index 00000000000..90f2d9907b9 --- /dev/null +++ b/packages/manager/.changeset/pr-10496-tech-stories-1716315886330.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Query Key Factory for Support Tickets ([#10496](https://github.com/linode/manager/pull/10496)) diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts index d5a3b762de9..de1525a929d 100644 --- a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts +++ b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts @@ -75,7 +75,6 @@ describe('help & support', () => { // intercept create ticket request, stub response. mockCreateSupportTicket(mockTicketData).as('createTicket'); - mockGetSupportTicket(mockTicketData).as('getTicket'); mockGetSupportTicketReplies(ticketId, []).as('getReplies'); mockAttachSupportTicketFile(ticketId).as('attachmentPost'); @@ -94,7 +93,6 @@ describe('help & support', () => { cy.wait('@createTicket').its('response.statusCode').should('eq', 200); cy.wait('@attachmentPost').its('response.statusCode').should('eq', 200); cy.wait('@getReplies').its('response.statusCode').should('eq', 200); - cy.wait('@getTicket').its('response.statusCode').should('eq', 200); containsVisible(`#${ticketId}: ${ticketLabel}`); containsVisible(ticketDescription); diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx index 4069ef30fd5..3d092c82b93 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketDialog.tsx @@ -1,8 +1,4 @@ -import { - TicketSeverity, - createSupportTicket, - uploadAttachment, -} from '@linode/api-v4/lib/support'; +import { TicketSeverity, uploadAttachment } from '@linode/api-v4/lib/support'; import { APIError } from '@linode/api-v4/lib/types'; import { Theme } from '@mui/material/styles'; import { update } from 'ramda'; @@ -27,6 +23,7 @@ import { useAllFirewallsQuery } from 'src/queries/firewalls'; import { useAllKubernetesClustersQuery } from 'src/queries/kubernetes'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; +import { useCreateSupportTicketMutation } from 'src/queries/support'; import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; import { getAPIErrorOrDefault, @@ -220,6 +217,8 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { useCase: '', }); + const { mutateAsync: createSupportTicket } = useCreateSupportTicketMutation(); + const [files, setFiles] = React.useState([]); const [errors, setErrors] = React.useState(); diff --git a/packages/manager/src/queries/support.ts b/packages/manager/src/queries/support.ts index 40bd2cf3961..5c4ea96838c 100644 --- a/packages/manager/src/queries/support.ts +++ b/packages/manager/src/queries/support.ts @@ -7,7 +7,10 @@ import { getTicket, getTicketReplies, getTickets, + createSupportTicket, + TicketRequest, } from '@linode/api-v4/lib/support'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useInfiniteQuery, useMutation, @@ -24,54 +27,113 @@ import type { ResourcePage, } from '@linode/api-v4/lib/types'; -const queryKey = `tickets`; +const supportQueries = createQueryKeys('support', { + ticket: (id: number) => ({ + contextQueries: { + replies: { + queryFn: ({ pageParam }) => + getTicketReplies(id, { page: pageParam, page_size: 25 }), + queryKey: null, + }, + }, + queryFn: () => getTicket(id), + queryKey: [id], + }), + tickets: (params: Params, filter: Filter) => ({ + queryFn: () => getTickets(params, filter), + queryKey: [params, filter], + }), +}); export const useSupportTicketsQuery = (params: Params, filter: Filter) => - useQuery, APIError[]>( - [queryKey, 'paginated', params, filter], - () => getTickets(params, filter), - { keepPreviousData: true } - ); + useQuery, APIError[]>({ + ...supportQueries.tickets(params, filter), + keepPreviousData: true, + }); export const useSupportTicketQuery = (id: number) => - useQuery([queryKey, 'ticket', id], () => - getTicket(id) - ); + useQuery(supportQueries.ticket(id)); + +export const useCreateSupportTicketMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createSupportTicket, + onSuccess(ticket) { + queryClient.invalidateQueries({ queryKey: supportQueries.tickets._def }); + queryClient.setQueryData( + supportQueries.ticket(ticket.id).queryKey, + ticket + ); + }, + }); +}; export const useInfiniteSupportTicketRepliesQuery = (id: number) => - useInfiniteQuery, APIError[]>( - [queryKey, 'ticket', id, 'replies'], - ({ pageParam }) => getTicketReplies(id, { page: pageParam, page_size: 25 }), - { - getNextPageParam: ({ page, pages }) => { - if (page === pages) { - return undefined; - } - return page + 1; - }, - } - ); + useInfiniteQuery, APIError[]>({ + ...supportQueries.ticket(id)._ctx.replies, + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + }); export const useSupportTicketReplyMutation = () => { const queryClient = useQueryClient(); - return useMutation(createReply, { - onSuccess() { - queryClient.invalidateQueries([queryKey]); + return useMutation({ + mutationFn: createReply, + onSuccess(data, variables) { + queryClient.invalidateQueries({ + queryKey: supportQueries.tickets._def, + }); + queryClient.invalidateQueries({ + queryKey: supportQueries.ticket(variables.ticket_id).queryKey, + }); }, }); }; export const useSupportTicketCloseMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => closeSupportTicket(id), { + return useMutation<{}, APIError[]>({ + mutationFn: () => closeSupportTicket(id), onSuccess() { - queryClient.invalidateQueries([queryKey]); + queryClient.invalidateQueries({ + queryKey: supportQueries.tickets._def, + }); + queryClient.invalidateQueries({ + queryKey: supportQueries.ticket(id).queryKey, + }); }, }); }; export const supportTicketEventHandler = ({ + event, queryClient, }: EventHandlerData) => { - queryClient.invalidateQueries([queryKey]); + /** + * Ticket events have entities that look like this: + * + * "entity": { + * "label": "Great news! We're upgrading your Block Storage", + * "id": 3674063, + * "type": "ticket", + * "url": "/v4/support/tickets/3674063" + * } + */ + + // Invalidate paginated support tickets + queryClient.invalidateQueries({ + queryKey: supportQueries.tickets._def, + }); + + if (event.entity) { + // If there is an entity associated with the event, invalidate that ticket + queryClient.invalidateQueries({ + queryKey: supportQueries.ticket(event.entity.id).queryKey, + }); + } }; From 3f15f99ec0fb5d384a8c60cb8ab32fb924d69743 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Wed, 29 May 2024 12:25:37 -0400 Subject: [PATCH 032/163] hotfix: [M3-8182] - Remove leaveDelay from TooltipText (#10523) * Remove erroneous leaveDelay from TooltipText * Add test * update changelog and bump version --------- Co-authored-by: Banks Nussman --- packages/manager/CHANGELOG.md | 7 ++++++ packages/manager/package.json | 2 +- .../TextTooltip/TextTooltip.test.tsx | 23 ++++++++++++++++++- .../components/TextTooltip/TextTooltip.tsx | 1 - 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 8ab21bda0ad..2ac4e25a7b6 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2024-05-29] - v1.120.1 + + +### Fixed: + +- Tooltip not closing when unhovered ([#10523](https://github.com/linode/manager/pull/10523)) + ## [2024-05-28] - v1.120.0 diff --git a/packages/manager/package.json b/packages/manager/package.json index 8d130cfd0ba..1b6e5bf0ea2 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.120.0", + "version": "1.120.1", "private": true, "type": "module", "bugs": { diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx index 7a44e14538e..849a034e535 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent } from '@testing-library/react'; +import { fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; @@ -59,4 +59,25 @@ describe('TextTooltip', () => { expect(displayText).toHaveStyle('color: rgb(54, 131, 220)'); expect(displayText).toHaveStyle('font-size: 18px'); }); + + it('the tooltip should disappear on mouseout', async () => { + const props = { + displayText: 'Hover me', + tooltipText: 'This is a tooltip', + }; + + const { findByRole, getByText, queryByRole } = renderWithTheme( + + ); + + fireEvent.mouseEnter(getByText(props.displayText)); + + const tooltip = await findByRole('tooltip'); + + expect(tooltip).toBeInTheDocument(); + + fireEvent.mouseLeave(getByText(props.displayText)); + + await waitFor(() => expect(queryByRole('tooltip')).not.toBeInTheDocument()); + }); }); diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.tsx index 7c49761865f..e480ea3f0c6 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.tsx @@ -66,7 +66,6 @@ export const TextTooltip = (props: TextTooltipProps) => { }, }, }} - leaveDelay={500000} data-qa-tooltip={dataQaTooltip} enterTouchDelay={0} placement={placement ? placement : 'bottom'} From c4b60aed0614fc1aac6de4790f405695b6896e1c Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Wed, 29 May 2024 12:28:01 -0700 Subject: [PATCH 033/163] fix: [M3-7977] - Surface interface error in Linode Config dialog (#10429) * Surface interface select errors * Revert unintentional grid change * Fix styling for surfaced error * Added changeset: Surface interface error in Linode Config dialog --- .../pr-10429-fixed-1716930064091.md | 5 ++ .../LinodeSettings/InterfaceSelect.tsx | 46 +++++++++++-------- 2 files changed, 32 insertions(+), 19 deletions(-) create mode 100644 packages/manager/.changeset/pr-10429-fixed-1716930064091.md diff --git a/packages/manager/.changeset/pr-10429-fixed-1716930064091.md b/packages/manager/.changeset/pr-10429-fixed-1716930064091.md new file mode 100644 index 00000000000..493441e9fe4 --- /dev/null +++ b/packages/manager/.changeset/pr-10429-fixed-1716930064091.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Surface interface error in Linode Config dialog ([#10429](https://github.com/linode/manager/pull/10429)) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx index 524eda85802..e4f9ccdbdb4 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx @@ -5,6 +5,7 @@ import * as React from 'react'; import { Divider } from 'src/components/Divider'; import Select from 'src/components/EnhancedSelect/Select'; +import { Notice } from 'src/components/Notice/Notice'; import { Stack } from 'src/components/Stack'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; @@ -384,26 +385,33 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { return ( {fromAddonsPanel ? null : ( - - 0 + ? purposeOptions + : purposeOptions.filter( + (thisPurposeOption) => thisPurposeOption.value !== 'none' + ) + } + value={purposeOptions.find( + (thisOption) => thisOption.value === purpose + )} + disabled={readOnly} + isClearable={false} + label={`eth${slotNumber}`} + onChange={handlePurposeChange} + /> + {unavailableInRegionHelperTextJSX} + + )} {purpose === 'vlan' && regionHasVLANs !== false && From 1a1067b4cb914dbb8f8ef4207a69ee0d432ca62d Mon Sep 17 00:00:00 2001 From: santoshp210 <159890961+santoshp210@users.noreply.github.com> Date: Thu, 30 May 2024 18:41:02 +0530 Subject: [PATCH 034/163] upcoming: [DI-18311] - Adding CloudPulse section to Cloud Manager (#10397) --- packages/api-v4/src/account/types.ts | 1 + ...r-10397-upcoming-features-1713875567593.md | 5 + packages/manager/src/MainContent.tsx | 6 +- .../src/components/PrimaryNav/PrimaryNav.tsx | 6 +- packages/manager/src/featureFlags.ts | 1 - .../features/CloudPulse/CloudPulseLanding.tsx | 16 +- .../features/CloudPulse/CloudPulseTabs.tsx | 55 +++++++ .../CloudPulse/Dashboard/DashboardLanding.tsx | 18 +++ .../CloudPulse/Overview/GlobalFilters.tsx | 102 +++++++++++++ .../CloudPulse/shared/RegionSelect.test.tsx | 19 +++ .../CloudPulse/shared/RegionSelect.tsx | 36 +++++ .../shared/TimeRangeSelect.test.tsx | 49 ++++++ .../CloudPulse/shared/TimeRangeSelect.tsx | 143 ++++++++++++++++++ .../manager/src/features/CloudPulse/utils.ts | 20 +++ 14 files changed, 469 insertions(+), 8 deletions(-) create mode 100644 packages/manager/.changeset/pr-10397-upcoming-features-1713875567593.md create mode 100644 packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx create mode 100644 packages/manager/src/features/CloudPulse/Dashboard/DashboardLanding.tsx create mode 100644 packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx create mode 100644 packages/manager/src/features/CloudPulse/shared/RegionSelect.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/shared/RegionSelect.tsx create mode 100644 packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.tsx create mode 100644 packages/manager/src/features/CloudPulse/utils.ts diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 08b89d605e8..4fc523c2bb8 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -61,6 +61,7 @@ export type BillingSource = 'linode' | 'akamai'; export type AccountCapability = | 'Akamai Cloud Load Balancer' + | 'CloudPulse' | 'Block Storage' | 'Cloud Firewall' | 'Disk Encryption' diff --git a/packages/manager/.changeset/pr-10397-upcoming-features-1713875567593.md b/packages/manager/.changeset/pr-10397-upcoming-features-1713875567593.md new file mode 100644 index 00000000000..9d175c68a43 --- /dev/null +++ b/packages/manager/.changeset/pr-10397-upcoming-features-1713875567593.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Dashboard Global Filters and Dashboards Tab to the CloudPulse component ([#10397](https://github.com/linode/manager/pull/10397)) diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index 39de2ed2685..255005ebcdb 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -219,10 +219,12 @@ export const MainContent = () => { const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { data: accountSettings } = useAccountSettings(); + const defaultRoot = accountSettings?.managed ? '/managed' : '/linodes'; const showCloudPulse = Boolean(flags.aclp?.enabled); - - const defaultRoot = accountSettings?.managed ? '/managed' : '/linodes'; + // the followed comment is for later use, the showCloudPulse will be removed and isACLPEnabled will be used + // const { isACLPEnabled } = useIsACLPEnabled(); + /** * this is the case where the user has successfully completed signup diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 2921b0c7fcf..d467c6204de 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -157,9 +157,11 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const allowMarketplacePrefetch = !oneClickApps && !oneClickAppsLoading && !oneClickAppsError; -; - const showCloudPulse = Boolean(flags.aclp?.enabled); + const showCloudPulse = Boolean(flags.aclp?.enabled); + // the followed comment is for later use, the showCloudPulse will be removed and isACLPEnabled will be used + // const { isACLPEnabled } = useIsACLPEnabled(); + const { isACLBEnabled } = useIsACLBEnabled(); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); const { isDatabasesEnabled } = useIsDatabasesEnabled(); diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 775f369cb8b..1ee3379a571 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -56,7 +56,6 @@ interface AclpFlag { interface gpuV2 { planDivider: boolean; } - type OneClickApp = Record; export interface Flags { diff --git a/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx b/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx index 1ee9bb770c7..f8cfbe748fd 100644 --- a/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx +++ b/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx @@ -1,13 +1,23 @@ import * as React from 'react'; +import { Route, Switch } from 'react-router-dom'; import { LandingHeader } from 'src/components/LandingHeader/LandingHeader'; -import { Paper } from 'src/components/Paper'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { CloudPulseTabs } from './CloudPulseTabs'; export const CloudPulseLanding = () => { return ( <> - - + + }> + + + + ); }; diff --git a/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx b/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx new file mode 100644 index 00000000000..a6562db5204 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx @@ -0,0 +1,55 @@ +import { styled } from '@mui/material/styles'; +import * as React from 'react'; +import { RouteComponentProps, matchPath } from 'react-router-dom'; + +import { SuspenseLoader } from 'src/components/SuspenseLoader'; +import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; +import { TabLinkList } from 'src/components/Tabs/TabLinkList'; +import { TabPanels } from 'src/components/Tabs/TabPanels'; +import { Tabs } from 'src/components/Tabs/Tabs'; + +import { DashboardLanding } from './Dashboard/DashboardLanding'; +type Props = RouteComponentProps<{}>; + +export const CloudPulseTabs = React.memo((props: Props) => { + const tabs = [ + { + routeName: `${props.match.url}/dashboards`, + title: 'Dashboards', + }, + ]; + + const matches = (p: string) => { + return Boolean(matchPath(p, { path: props.location.pathname })); + }; + + const navToURL = (index: number) => { + props.history.push(tabs[index].routeName); + }; + + return ( + matches(tab.routeName)), + 0 + )} + onChange={navToURL} + > + + + }> + + + + + + + + ); +}); + +const StyledTabs = styled(Tabs, { + label: 'StyledTabs', +})(() => ({ + marginTop: 0, +})); diff --git a/packages/manager/src/features/CloudPulse/Dashboard/DashboardLanding.tsx b/packages/manager/src/features/CloudPulse/Dashboard/DashboardLanding.tsx new file mode 100644 index 00000000000..7cc07dc8cb9 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Dashboard/DashboardLanding.tsx @@ -0,0 +1,18 @@ +import { Paper } from '@mui/material'; +import * as React from 'react'; + +import { FiltersObject, GlobalFilters } from '../Overview/GlobalFilters'; + +export const DashboardLanding = () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + const onFilterChange = (_filters: FiltersObject) => {}; + return ( + +
+
+ +
+
+
+ ); +}; diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx new file mode 100644 index 00000000000..f5ad47c691d --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx @@ -0,0 +1,102 @@ +import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; +import * as React from 'react'; + +import { WithStartAndEnd } from 'src/features/Longview/request.types'; + +import { CloudPulseRegionSelect } from '../shared/RegionSelect'; +import { CloudPulseTimeRangeSelect } from '../shared/TimeRangeSelect'; + +export interface GlobalFilterProperties { + handleAnyFilterChange(filters: FiltersObject): undefined | void; +} + +export interface FiltersObject { + interval: string; + region: string; + resource: string[]; + serviceType?: string; + timeRange: WithStartAndEnd; +} + +export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { + const [time, setTimeBox] = React.useState({ + end: 0, + start: 0, + }); + + const [selectedRegion, setRegion] = React.useState(); + React.useEffect(() => { + const triggerGlobalFilterChange = () => { + const globalFilters: FiltersObject = { + interval: '', + region: '', + resource: [], + timeRange: time, + }; + if (selectedRegion) { + globalFilters.region = selectedRegion; + } + props.handleAnyFilterChange(globalFilters); + }; + triggerGlobalFilterChange(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [time, selectedRegion]); // if anything changes, emit an event to parent component + + const handleTimeRangeChange = React.useCallback( + (start: number, end: number) => { + setTimeBox({ end, start }); + }, + [] + ); + + const handleRegionChange = React.useCallback((region: string | undefined) => { + setRegion(region); + }, []); + + return ( + + + + + + + + + + + ); +}); + +const StyledCloudPulseRegionSelect = styled(CloudPulseRegionSelect, { + label: 'StyledCloudPulseRegionSelect', +})({ + width: 150, +}); + +const StyledCloudPulseTimeRangeSelect = styled(CloudPulseTimeRangeSelect, { + label: 'StyledCloudPulseTimeRangeSelect', +})({ + width: 150, +}); + +const StyledGrid = styled(Grid, { label: 'StyledGrid' })(({ theme }) => ({ + alignItems: 'end', + boxSizing: 'border-box', + display: 'flex', + flexDirection: 'row', + justifyContent: 'start', + marginBottom: theme.spacing(1.25), +})); + +const itemSpacing = { + boxSizing: 'border-box', + margin: '0', +}; diff --git a/packages/manager/src/features/CloudPulse/shared/RegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/RegionSelect.test.tsx new file mode 100644 index 00000000000..c1e24bb8f84 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/RegionSelect.test.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseRegionSelectProps } from './RegionSelect'; +import { CloudPulseRegionSelect } from './RegionSelect'; + +const props: CloudPulseRegionSelectProps = { + handleRegionChange: vi.fn(), +}; + +describe('CloudViewRegionSelect', () => { + it('should render a Region Select component', () => { + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId('region-select')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/shared/RegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/RegionSelect.tsx new file mode 100644 index 00000000000..b60ba23564f --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/RegionSelect.tsx @@ -0,0 +1,36 @@ +/* eslint-disable no-console */ +import * as React from 'react'; + +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +export interface CloudPulseRegionSelectProps { + handleRegionChange: (region: string | undefined) => void; +} + +export const CloudPulseRegionSelect = React.memo( + (props: CloudPulseRegionSelectProps) => { + const { data: regions } = useRegionsQuery(); + const [selectedRegion, setRegion] = React.useState(); + + React.useEffect(() => { + props.handleRegionChange(selectedRegion); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedRegion]); + + return ( + { + setRegion(value); + }} + currentCapability={undefined} + fullWidth + isClearable={false} + label="" + noMarginTop + regions={regions ? regions : []} + selectedId={null} + /> + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.test.tsx new file mode 100644 index 00000000000..c7fd4c7020f --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.test.tsx @@ -0,0 +1,49 @@ +import { DateTime } from 'luxon'; + +import { generateStartTime } from './TimeRangeSelect'; + +describe('Utility Functions', () => { + it('should create values as functions that return the correct datetime', () => { + const GMT_november_20_2019_849PM = 1574282998; + + expect( + generateStartTime('Past 30 Minutes', GMT_november_20_2019_849PM) + ).toEqual( + DateTime.fromSeconds(GMT_november_20_2019_849PM) + .minus({ minutes: 30 }) + .toSeconds() + ); + + expect( + generateStartTime('Past 12 Hours', GMT_november_20_2019_849PM) + ).toEqual( + DateTime.fromSeconds(GMT_november_20_2019_849PM) + .minus({ hours: 12 }) + .toSeconds() + ); + + expect( + generateStartTime('Past 24 Hours', GMT_november_20_2019_849PM) + ).toEqual( + DateTime.fromSeconds(GMT_november_20_2019_849PM) + .minus({ hours: 24 }) + .toSeconds() + ); + + expect( + generateStartTime('Past 7 Days', GMT_november_20_2019_849PM) + ).toEqual( + DateTime.fromSeconds(GMT_november_20_2019_849PM) + .minus({ days: 7 }) + .toSeconds() + ); + + expect( + generateStartTime('Past 30 Days', GMT_november_20_2019_849PM) + ).toEqual( + DateTime.fromSeconds(GMT_november_20_2019_849PM) + .minus({ hours: 24 * 30 }) + .toSeconds() + ); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.tsx b/packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.tsx new file mode 100644 index 00000000000..8a7d8740592 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.tsx @@ -0,0 +1,143 @@ +import * as React from 'react'; + +import Select, { + BaseSelectProps, + Item, +} from 'src/components/EnhancedSelect/Select'; + +interface Props + extends Omit< + BaseSelectProps, false>, + 'defaultValue' | 'onChange' + > { + defaultValue?: Labels; + handleStatsChange?: (start: number, end: number) => void; +} + +const PAST_7_DAYS = 'Past 7 Days'; +const PAST_12_HOURS = 'Past 12 Hours'; +const PAST_24_HOURS = 'Past 24 Hours'; +const PAST_30_DAYS = 'Past 30 Days'; +const PAST_30_MINUTES = 'Past 30 Minutes'; +export type Labels = + | 'Past 7 Days' + | 'Past 12 Hours' + | 'Past 24 Hours' + | 'Past 30 Days' + | 'Past 30 Minutes'; + +export const CloudPulseTimeRangeSelect = React.memo((props: Props) => { + const { defaultValue, handleStatsChange, ...restOfSelectProps } = props; + + /* + the time range is the label instead of the value because it's a lot harder + to keep Date.now() consistent with this state. We can get the actual + values when it comes time to make the request. + + Use the value from user preferences if available, then fall back to + the default that was passed to the component, and use Past 30 Minutes + if all else fails. + + @todo Validation here to make sure that the value from user preferences + is a valid time window. + */ + const [selectedTimeRange, setTimeRange] = React.useState( + PAST_30_MINUTES + ); + + /* + Why division by 1000? + + Because the LongView API doesn't expect the start and date time + to the nearest millisecond - if you send anything more than 10 digits + you won't get any data back + */ + const nowInSeconds = Date.now() / 1000; + + React.useEffect(() => { + // Do the math and send start/end values to the consumer + // (in most cases the consumer has passed defaultValue={'last 30 minutes'} + // but the calcs to turn that into start/end numbers live here) + if (!!handleStatsChange) { + handleStatsChange( + Math.round(generateStartTime(selectedTimeRange, nowInSeconds)), + Math.round(nowInSeconds) + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedTimeRange]); + + const options = generateSelectOptions(); + + const handleChange = (item: Item) => { + setTimeRange(item.value); + }; + + return ( + ) => - formik.setFieldValue('config_id', Number(item.value)) - } - value={configChoices.find( - (item) => item.value === String(formik.values.config_id) - )} - disabled={isReadOnly || formik.values.linode_id === -1} - errorText={formik.errors.config_id ?? configError} - id="config_id" - isClearable={false} - isLoading={configsLoading} - label="Config" - name="config_id" - noMarginTop - options={configChoices} - placeholder="Select a Config" - /> - + { + formik.setFieldValue('config_id', +id); + }} + disabled={isReadOnly || formik.values.linode_id === -1} + name="configId" + onBlur={() => null} + value={formik.values.config_id} + /> + {
); }); + +const StyledConfigSelect = styled(ConfigSelect, { + label: 'StyledConfigSelect', +})(() => ({ + p: { marginLeft: 0 }, +})); diff --git a/packages/manager/src/features/Volumes/VolumeCreate.tsx b/packages/manager/src/features/Volumes/VolumeCreate.tsx index 1725e7d9d84..8b611c74ea1 100644 --- a/packages/manager/src/features/Volumes/VolumeCreate.tsx +++ b/packages/manager/src/features/Volumes/VolumeCreate.tsx @@ -364,7 +364,7 @@ export const VolumeCreate = () => { )} { } } - if (linodeId === null) { - return null; - } - return ( -
- - - Status - Message - - - - {Object.keys(statuses).map((status, key) => { - const messageCreator = statuses[status]; - - let message = messageCreator(event); - message = applyBolding(message); - message = formatEventWithUsername( - event.action, - event.username, - message - ); - // eslint-disable-next-line xss/no-mixed-html - message = unsafe_MarkdownIt.render(message); - message = applyLinking(event, message); - - return ( - - - {' '} - - - - - - ); - })} - -
- - ))} - - ); -}; - -export const HardCodedMessages: StoryObj = { - render: () => renderEventMessages(eventMessageCreators), -}; - -const customizableEvent: Event = eventFactory.build(); - -export const EventPlayground: StoryObj = { - argTypes: { - action: { - control: 'select', - options: EVENT_ACTIONS, - }, - status: { - control: 'select', - options: EVENT_STATUSES, - }, - }, - args: { - ...customizableEvent, - }, - render: (args) => ( - - ), -}; - -/** - * This renderer only loops through hard coded messages defined in `eventMessageCreators`. - * This means that it will not render messages coming straight from the API and therefore - * isn't an exhaustive list of all possible events. - * - * However a playground is available to generate message from a custom Event for testing purposes - */ - -const meta: Meta = { - args: {}, - title: 'Features/Events', -}; - -export default meta; diff --git a/packages/manager/src/features/Events/EventsLanding.styles.ts b/packages/manager/src/features/Events/EventsLanding.styles.ts index b4e5cf2e6dc..f0b853ebc4b 100644 --- a/packages/manager/src/features/Events/EventsLanding.styles.ts +++ b/packages/manager/src/features/Events/EventsLanding.styles.ts @@ -21,9 +21,9 @@ export const StyledLabelTableCell = styled(TableCell, { minWidth: 200, paddingLeft: 10, [theme.breakpoints.down('sm')]: { - width: '70%', + width: 'calc(100% - 250px)', }, - width: '60%', + width: 'calc(100% - 400px)', })); export const StyledH1Header = styled(H1Header, { diff --git a/packages/manager/src/features/Events/EventsLanding.tsx b/packages/manager/src/features/Events/EventsLanding.tsx index 7486341c5cb..6cb75abaa2b 100644 --- a/packages/manager/src/features/Events/EventsLanding.tsx +++ b/packages/manager/src/features/Events/EventsLanding.tsx @@ -10,9 +10,11 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; +import { useFlags } from 'src/hooks/useFlags'; import { useEventsInfiniteQuery } from 'src/queries/events/events'; import { EventRow } from './EventRow'; +import { EventRowV2 } from './EventRowV2'; import { StyledH1Header, StyledLabelTableCell, @@ -29,6 +31,7 @@ interface Props { export const EventsLanding = (props: Props) => { const { emptyMessage, entityId } = props; + const flags = useFlags(); const filter: Filter = { action: { '+neq': 'profile_update' } }; @@ -67,13 +70,21 @@ export const EventsLanding = (props: Props) => { } else { return ( <> - {events?.map((event) => ( - - ))} + {events?.map((event) => + flags.eventMessagesV2 ? ( + + ) : ( + + ) + )} {isFetchingNextPage && ( { - - - + {!flags.eventMessagesV2 && ( + + + + )} Event - Relative Date + {flags.eventMessagesV2 && ( + + + User + + + )} + Relative Date - + Absolute Date diff --git a/packages/manager/src/features/Events/EventsMessages.stories.tsx b/packages/manager/src/features/Events/EventsMessages.stories.tsx new file mode 100644 index 00000000000..72325c69c1f --- /dev/null +++ b/packages/manager/src/features/Events/EventsMessages.stories.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import { Chip } from 'src/components/Chip'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { Typography } from 'src/components/Typography'; +import { eventFactory } from 'src/factories/events'; +import { eventMessages } from 'src/features/Events/factory'; + +import type { Event } from '@linode/api-v4/lib/account'; +import type { Meta, StoryObj } from '@storybook/react'; + +const event: Event = eventFactory.build({ + action: 'linode_boot', + entity: { + id: 1, + label: '{entity}', + type: 'linode', + url: 'https://google.com', + }, + message: 'message with a `ticked` word', + secondary_entity: { + id: 1, + label: '{secondary entity}', + type: 'linode', + url: 'https://google.com', + }, + status: '{status}' as Event['status'], + username: '{username}', +}); + +/** + * This story loops through all the known event messages keys, and displays their Cloud Manager message in a table. + */ +export const EventMessages: StoryObj = { + render: () => ( +
+ {Object.entries(eventMessages).map(([eventKey, statuses]) => ( +
+ + {eventKey} + +
+ + + Status + Message + + + + {Object.keys(statuses).map((status, key) => { + const message = statuses[status](event); + + return ( + + + + + + {message} + + + ); + })} + +
+ + ))} + + ), +}; + +const meta: Meta = { + args: {}, + title: 'Features/Event Messages', +}; + +export default meta; diff --git a/packages/manager/src/features/Events/constants.ts b/packages/manager/src/features/Events/constants.ts index afc1092c862..bbf0ab2f5ab 100644 --- a/packages/manager/src/features/Events/constants.ts +++ b/packages/manager/src/features/Events/constants.ts @@ -1,3 +1,4 @@ +// TODO eventMessagesV2: delete when flag is removed import type { Event } from '@linode/api-v4/lib/account'; export const EVENT_ACTIONS: Event['action'][] = [ diff --git a/packages/manager/src/features/Events/eventMessageGenerator.ts b/packages/manager/src/features/Events/eventMessageGenerator.ts index 52695612984..4d187f19df4 100644 --- a/packages/manager/src/features/Events/eventMessageGenerator.ts +++ b/packages/manager/src/features/Events/eventMessageGenerator.ts @@ -1,3 +1,4 @@ +// TODO eventMessagesV2: delete when flag is removed import { Event } from '@linode/api-v4/lib/account'; import { path } from 'ramda'; diff --git a/packages/manager/src/features/Events/eventMessageGenerator_CMR.tsx b/packages/manager/src/features/Events/eventMessageGenerator_CMR.tsx index e50fae1b63e..c5001671c03 100644 --- a/packages/manager/src/features/Events/eventMessageGenerator_CMR.tsx +++ b/packages/manager/src/features/Events/eventMessageGenerator_CMR.tsx @@ -1,3 +1,4 @@ +// TODO eventMessagesV2: delete when flag is removed import { Event } from '@linode/api-v4/lib/account'; import { Linode } from '@linode/api-v4/lib/linodes'; import { Region } from '@linode/api-v4/lib/regions'; diff --git a/packages/manager/src/features/Events/factories/account.tsx b/packages/manager/src/features/Events/factories/account.tsx new file mode 100644 index 00000000000..d280e255480 --- /dev/null +++ b/packages/manager/src/features/Events/factories/account.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const account: PartialEventMap<'account'> = { + account_agreement_eu_model: { + notification: () => ( + <> + The EU Model Contract has been signed. + + ), + }, + account_promo_apply: { + notification: () => ( + <> + A promo code was applied to your account. + + ), + }, + account_settings_update: { + notification: () => ( + <> + Your account settings have been updated. + + ), + }, + account_update: { + notification: () => ( + <> + Your account has been updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/backup.tsx b/packages/manager/src/features/Events/factories/backup.tsx new file mode 100644 index 00000000000..50f17ec8d53 --- /dev/null +++ b/packages/manager/src/features/Events/factories/backup.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; + +import { Link } from 'src/components/Link'; + +import type { PartialEventMap } from '../types'; + +export const backup: PartialEventMap<'backups'> = { + backups_cancel: { + notification: (e) => ( + <> + Backups have been canceled for {e.entity!.label}. + + ), + }, + backups_enable: { + notification: (e) => ( + <> + Backups have been enabled for {e.entity!.label}. + + ), + }, + backups_restore: { + failed: (e) => ( + <> + Backup could not be restored for + {e.entity!.label}.{' '} + + Learn more about limits and considerations. + + + ), + finished: (e) => ( + <> + Backup restoration completed for {e.entity!.label}. + + ), + notification: (e) => ( + <> + Backup restoration completed for {e.entity!.label}. + + ), + scheduled: (e) => ( + <> + Backup restoration scheduled for {e.entity!.label}. + + ), + started: (e) => ( + <> + Backup restoration started for {e.entity!.label}. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/community.tsx b/packages/manager/src/features/Events/factories/community.tsx new file mode 100644 index 00000000000..acffa8ecb0b --- /dev/null +++ b/packages/manager/src/features/Events/factories/community.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const community: PartialEventMap<'community'> = { + community_like: { + notification: (e) => + e.entity?.label ? ( + <> + A post on has been{' '} + liked. + + ) : ( + <> + There has been a like on your community post. + + ), + }, + community_mention: { + notification: (e) => + e.entity?.label ? ( + <> + You have been mentioned in a post on{' '} + . + + ) : ( + <> + You have been mentioned in a community post. + + ), + }, + community_question_reply: { + notification: (e) => + e.entity?.label ? ( + <> + A reply has been posted to your question on{' '} + . + + ) : ( + <> + A reply has been posted to your question. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/credit.tsx b/packages/manager/src/features/Events/factories/credit.tsx new file mode 100644 index 00000000000..82323752152 --- /dev/null +++ b/packages/manager/src/features/Events/factories/credit.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const creditCard: PartialEventMap<'credit'> = { + credit_card_updated: { + notification: (e) => ( + <> + Your credit card information has been updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/database.tsx b/packages/manager/src/features/Events/factories/database.tsx new file mode 100644 index 00000000000..b34114cc9e8 --- /dev/null +++ b/packages/manager/src/features/Events/factories/database.tsx @@ -0,0 +1,192 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const database: PartialEventMap<'database'> = { + database_backup_create: { + notification: (e) => ( + <> + Database backup has been{' '} + created. + + ), + }, + database_backup_delete: { + notification: (e) => ( + <> + Database backup {e.entity?.label} has been deleted. + + ), + }, + database_backup_restore: { + notification: (e) => ( + <> + Database has been{' '} + restored from a backup. + + ), + }, + database_create: { + failed: (e) => ( + <> + Database could not{' '} + be created. + + ), + finished: (e) => ( + <> + Database has been{' '} + created. + + ), + notification: (e) => ( + <> + Database has been{' '} + created. + + ), + scheduled: (e) => ( + <> + Database is scheduled for{' '} + creation. + + ), + started: (e) => ( + <> + Database is being{' '} + created. + + ), + }, + database_credentials_reset: { + notification: (e) => ( + <> + Database credentials have been{' '} + reset. + + ), + }, + database_degraded: { + notification: (e) => ( + <> + Database has been{' '} + degraded. + + ), + }, + database_delete: { + notification: (e) => ( + <> + Database {e.entity?.label} has been deleted. + + ), + }, + database_failed: { + notification: (e) => ( + <> + Database could not{' '} + be updated. + + ), + }, + database_low_disk_space: { + finished: (e) => ( + <> + Low disk space alert for database {' '} + has cleared. + + ), + + notification: (e) => ( + <> + Database has{' '} + low disk space. + + ), + }, + database_resize: { + failed: (e) => ( + <> + Database could not{' '} + be resized. + + ), + finished: (e) => ( + <> + Database has been{' '} + resized. + + ), + scheduled: (e) => ( + <> + Database is scheduled for{' '} + resizing. + + ), + started: (e) => ( + <> + Database is{' '} + resizing. + + ), + }, + database_resize_create: { + notification: (e) => ( + <> + Database scheduled to be{' '} + resized. + + ), + }, + database_scale: { + failed: (e) => ( + <> + Database could not{' '} + be resized. + + ), + finished: (e) => ( + <> + Database has been{' '} + resized. + + ), + scheduled: (e) => ( + <> + Database is scheduled for{' '} + resizing. + + ), + started: (e) => ( + <> + Database is{' '} + resizing. + + ), + }, + database_update: { + finished: (e) => ( + <> + Database has been{' '} + updated. + + ), + }, + database_update_failed: { + notification: (e) => ( + <> + Database could not{' '} + be updated. + + ), + }, + database_upgrade: { + notification: (e) => ( + <> + Database {e.entity?.label} has been upgraded. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/disk.tsx b/packages/manager/src/features/Events/factories/disk.tsx new file mode 100644 index 00000000000..8168cdd76e4 --- /dev/null +++ b/packages/manager/src/features/Events/factories/disk.tsx @@ -0,0 +1,182 @@ +import * as React from 'react'; + +import { Link } from 'src/components/Link'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const disk: PartialEventMap<'disk'> = { + disk_create: { + failed: (e) => ( + <> + Disk could{' '} + not be added to Linode{' '} + . + + ), + finished: (e) => ( + <> + Disk has been{' '} + added to Linode . + + ), + notification: (e) => ( + <> + Disk has been{' '} + added to Linode . + + ), + scheduled: (e) => ( + <> + Disk is being{' '} + added to Linode . + + ), + started: (e) => ( + <> + Disk is being{' '} + added to . + + ), + }, + disk_delete: { + failed: (e) => ( + <> + Disk could{' '} + not be deleted on Linode{' '} + . + + ), + finished: (e) => ( + <> + Disk {e.secondary_entity?.label} on Linode{' '} + has been deleted. + + ), + notification: (e) => ( + <> + Disk {e.secondary_entity?.label} on Linode{' '} + has been deleted. + + ), + scheduled: (e) => ( + <> + Disk on Linode{' '} + is scheduled for deletion. + + ), + started: (e) => ( + <> + Disk on Linode{' '} + is being deleted. + + ), + }, + disk_duplicate: { + failed: (e) => ( + <> + Disk on Linode could{' '} + not be duplicated. + + ), + finished: (e) => ( + <> + Disk on Linode has been{' '} + duplicated. + + ), + notification: (e) => ( + <> + Disk on Linode has been{' '} + duplicated. + + ), + scheduled: (e) => ( + <> + Disk on Linode is scheduled to be{' '} + duplicated. + + ), + started: (e) => ( + <> + Disk on Linode is being{' '} + duplicated. + + ), + }, + disk_imagize: { + failed: (e) => ( + <> + Image could{' '} + not be created.{' '} + + Learn more about image technical specifications. + + . + + ), + finished: (e) => ( + <> + Image has been{' '} + created. + + ), + scheduled: (e) => ( + <> + Image is scheduled to be{' '} + created. + + ), + started: (e) => ( + <> + Image is being{' '} + created. + + ), + }, + disk_resize: { + failed: (e) => ( + <> + A disk on Linode could{' '} + not be resized.{' '} + + Learn more + + + ), + + finished: (e) => ( + <> + A disk on Linode has been{' '} + resized. + + ), + notification: (e) => ( + <> + A disk on Linode has been{' '} + resized. + + ), + scheduled: (e) => ( + <> + A disk on Linode is scheduled to be{' '} + resized. + + ), + started: (e) => ( + <> + A disk on Linode is being{' '} + resized. + + ), + }, + disk_update: { + notification: (e) => ( + <> + Disk has been{' '} + updated on Linode . + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/dns.tsx b/packages/manager/src/features/Events/factories/dns.tsx new file mode 100644 index 00000000000..88beb24a11c --- /dev/null +++ b/packages/manager/src/features/Events/factories/dns.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const dns: PartialEventMap<'dns'> = { + dns_record_create: { + notification: (e) => ( + <> + DNS record has been added to{' '} + . + + ), + }, + dns_record_delete: { + notification: (e) => ( + <> + DNS record has been removed from{' '} + . + + ), + }, + dns_zone_create: { + notification: (e) => ( + <> + DNS zone has been added to{' '} + . + + ), + }, + dns_zone_delete: { + notification: (e) => ( + <> + DNS zone has been removed from{' '} + . + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/domain.tsx b/packages/manager/src/features/Events/factories/domain.tsx new file mode 100644 index 00000000000..c6b02034ca3 --- /dev/null +++ b/packages/manager/src/features/Events/factories/domain.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; +import { EventMessage } from '../EventMessage'; + +import type { PartialEventMap } from '../types'; + +export const domain: PartialEventMap<'domain'> = { + domain_create: { + notification: (e) => ( + <> + Domain has been{' '} + created. + + ), + }, + domain_delete: { + notification: (e) => ( + <> + Domain {e.entity?.label} has been deleted. + + ), + }, + domain_import: { + notification: (e) => ( + <> + Domain has been{' '} + imported. + + ), + }, + domain_record_create: { + notification: (e) => ( + <> + has been added to{' '} + . + + ), + }, + domain_record_delete: { + notification: (e) => ( + <> + A domain record has been deleted from{' '} + . + + ), + }, + domain_record_update: { + notification: (e) => ( + <> + has been updated{' '} + for . + + ), + }, + domain_record_updated: { + notification: (e) => ( + <> + has been updated{' '} + for . + + ), + }, + domain_update: { + notification: (e) => ( + <> + Domain has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/entity.tsx b/packages/manager/src/features/Events/factories/entity.tsx new file mode 100644 index 00000000000..33b9afbc842 --- /dev/null +++ b/packages/manager/src/features/Events/factories/entity.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const entity: PartialEventMap<'entity'> = { + entity_transfer_accept: { + notification: () => ( + <> + A service transfer has been accepted. + + ), + }, + entity_transfer_accept_recipient: { + notification: () => ( + <> + You have accepted a service transfer. + + ), + }, + entity_transfer_cancel: { + notification: () => ( + <> + A service transfer has been canceled. + + ), + }, + entity_transfer_create: { + notification: () => ( + <> + A service transfer has been created. + + ), + }, + entity_transfer_fail: { + notification: () => ( + <> + A service transfer could not be{' '} + created. + + ), + }, + entity_transfer_stale: { + notification: () => ( + <> + A service transfer token has expired. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/firewall.tsx b/packages/manager/src/features/Events/factories/firewall.tsx new file mode 100644 index 00000000000..86f180fc1d9 --- /dev/null +++ b/packages/manager/src/features/Events/factories/firewall.tsx @@ -0,0 +1,114 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; +import type { FirewallDeviceEntityType } from '@linode/api-v4'; + +const secondaryFirewallEntityNameMap: Record< + FirewallDeviceEntityType, + string +> = { + linode: 'Linode', + nodebalancer: 'NodeBalancer', +}; + +export const firewall: PartialEventMap<'firewall'> = { + firewall_apply: { + notification: (e) => ( + <> + Firewall has been{' '} + applied. + + ), + }, + firewall_create: { + notification: (e) => ( + <> + Firewall has been{' '} + created. + + ), + }, + firewall_delete: { + notification: (e) => ( + <> + Firewall {e.entity?.label} has been deleted. + + ), + }, + firewall_device_add: { + notification: (e) => { + if (e.secondary_entity?.type) { + const secondaryEntityName = + secondaryFirewallEntityNameMap[e.secondary_entity.type]; + return ( + <> + {secondaryEntityName} {' '} + has been added to Firewall{' '} + . + + ); + } + return ( + <> + A device has been added to Firewall{' '} + . + + ); + }, + }, + firewall_device_remove: { + notification: (e) => { + if (e.secondary_entity?.type) { + const secondaryEntityName = + secondaryFirewallEntityNameMap[e.secondary_entity.type]; + return ( + <> + {secondaryEntityName} {' '} + has been removed from Firewall{' '} + . + + ); + } + return ( + <> + A device has been removed from Firewall{' '} + . + + ); + }, + }, + firewall_disable: { + notification: (e) => ( + <> + Firewall has been{' '} + disabled. + + ), + }, + firewall_enable: { + notification: (e) => ( + <> + Firewall has been{' '} + enabled. + + ), + }, + firewall_rules_update: { + notification: (e) => ( + <> + Firewall rules have been updated on{' '} + . + + ), + }, + firewall_update: { + notification: (e) => ( + <> + Firewall has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/host.tsx b/packages/manager/src/features/Events/factories/host.tsx new file mode 100644 index 00000000000..97b81ed9748 --- /dev/null +++ b/packages/manager/src/features/Events/factories/host.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const host: PartialEventMap<'host'> = { + host_reboot: { + failed: (e) => ( + <> + Linode could not be{' '} + booted (Host initiated restart). + + ), + finished: (e) => ( + <> + Linode has been{' '} + booted (Host initiated restart). + + ), + + scheduled: (e) => ( + <> + Linode is scheduled for a{' '} + reboot (Host initiated restart). + + ), + + started: (e) => ( + <> + Linode is being{' '} + booted (Host initiated restart). + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/image.tsx b/packages/manager/src/features/Events/factories/image.tsx new file mode 100644 index 00000000000..dbe4ef39cf2 --- /dev/null +++ b/packages/manager/src/features/Events/factories/image.tsx @@ -0,0 +1,79 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const image: PartialEventMap<'image'> = { + image_delete: { + failed: (e) => ( + <> + Image could not be{' '} + deleted. + + ), + finished: (e) => ( + <> + Image {e.entity?.label} has been deleted. + + ), + notification: (e) => ( + <> + Image {e.entity?.label} has been deleted. + + ), + scheduled: (e) => ( + <> + Image is scheduled to be{' '} + deleted. + + ), + started: (e) => ( + <> + Image is being{' '} + deleted. + + ), + }, + image_update: { + notification: (e) => ( + <> + Image has been{' '} + updated. + + ), + }, + image_upload: { + failed: (e) => ( + <> + Image could not be{' '} + uploaded: {e?.message?.replace(/(\d+)/g, '$1 MB')}. + + ), + + finished: (e) => ( + <> + Image has been{' '} + uploaded. + + ), + notification: (e) => ( + <> + Image has been{' '} + uploaded. + + ), + scheduled: (e) => ( + <> + Image is scheduled for{' '} + upload. + + ), + started: (e) => ( + <> + Image is being{' '} + uploaded. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/index.ts b/packages/manager/src/features/Events/factories/index.ts new file mode 100644 index 00000000000..4094d795200 --- /dev/null +++ b/packages/manager/src/features/Events/factories/index.ts @@ -0,0 +1,37 @@ +export * from './account'; +export * from './backup'; +export * from './community'; +export * from './credit'; +export * from './database'; +export * from './disk'; +export * from './dns'; +export * from './domain'; +export * from './entity'; +export * from './firewall'; +export * from './host'; +export * from './image'; +export * from './ipaddress'; +export * from './ipv6pool'; +export * from './lassie'; +export * from './linode'; +export * from './lish'; +export * from './lke'; +export * from './longviewclient'; +export * from './managed'; +export * from './nodebalancer'; +export * from './oAuth'; +export * from './obj'; +export * from './password'; +export * from './payment'; +export * from './placement'; +export * from './profile'; +export * from './reserved'; +export * from './stackscript'; +export * from './subnet'; +export * from './tag'; +export * from './tfa'; +export * from './ticket'; +export * from './token'; +export * from './user'; +export * from './volume'; +export * from './vpc'; diff --git a/packages/manager/src/features/Events/factories/ipaddress.tsx b/packages/manager/src/features/Events/factories/ipaddress.tsx new file mode 100644 index 00000000000..c4d1e973536 --- /dev/null +++ b/packages/manager/src/features/Events/factories/ipaddress.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const ip: PartialEventMap<'ipaddress'> = { + ipaddress_update: { + notification: () => ( + <> + An IP address has been updated on your account. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/ipv6pool.tsx b/packages/manager/src/features/Events/factories/ipv6pool.tsx new file mode 100644 index 00000000000..eab1111de04 --- /dev/null +++ b/packages/manager/src/features/Events/factories/ipv6pool.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const ipv6pool: PartialEventMap<'ipv6pool'> = { + ipv6pool_add: { + notification: () => ( + <> + An IPv6 range has been added to your account. + + ), + }, + ipv6pool_delete: { + notification: () => ( + <> + An IPv6 range has been deleted from your account. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/lassie.tsx b/packages/manager/src/features/Events/factories/lassie.tsx new file mode 100644 index 00000000000..27cd4811843 --- /dev/null +++ b/packages/manager/src/features/Events/factories/lassie.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const lassie: PartialEventMap<'lassie'> = { + lassie_reboot: { + failed: (e) => ( + <> + Linode could not be booted by the + Lassie watchdog service. + + ), + finished: (e) => ( + <> + Linode has been{' '} + booted by the Lassie watchdog service. + + ), + scheduled: (e) => ( + <> + Linode is scheduled to be{' '} + rebooted by the Lassie watchdog service. + + ), + started: (e) => ( + <> + Linode is being{' '} + booted by the Lassie watchdog service. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/linode.tsx b/packages/manager/src/features/Events/factories/linode.tsx new file mode 100644 index 00000000000..7dd6a42376b --- /dev/null +++ b/packages/manager/src/features/Events/factories/linode.tsx @@ -0,0 +1,542 @@ +import * as React from 'react'; + +import { Link } from 'src/components/Link'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const linode: PartialEventMap<'linode'> = { + linode_addip: { + notification: (e) => ( + <> + An IP address has been added to Linode{' '} + . + + ), + }, + linode_boot: { + failed: (e) => ( + <> + Linode could not be{' '} + booted + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ), + finished: (e) => ( + <> + Linode has been{' '} + booted + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ), + scheduled: (e) => ( + <> + Linode is scheduled to be{' '} + booted + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ), + started: (e) => ( + <> + Linode is being{' '} + booted + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ), + }, + linode_clone: { + failed: (e) => ( + <> + Linode {e.entity?.label} could not be{' '} + cloned + {e.secondary_entity ? ( + <> + {' '} + to + + ) : ( + '' + )} + . + + ), + finished: (e) => ( + <> + Linode has been{' '} + cloned + {e.secondary_entity ? ( + <> + {' '} + to + + ) : ( + '' + )} + . + + ), + notification: (e) => ( + <> + Linode has been{' '} + cloned + {e.secondary_entity ? ( + <> + {' '} + to + + ) : ( + '' + )} + . + + ), + scheduled: (e) => ( + <> + Linode is scheduled to be{' '} + cloned + {e.secondary_entity ? ( + <> + {' '} + to + + ) : ( + '' + )} + . + + ), + started: (e) => ( + <> + Linode is being{' '} + cloned + {e.secondary_entity ? ( + <> + {' '} + to + + ) : ( + '' + )} + . + + ), + }, + linode_config_create: { + notification: (e) => ( + <> + Config has been{' '} + created on Linode . + + ), + }, + linode_config_delete: { + notification: (e) => ( + <> + Config {e.secondary_entity?.label} has been deleted on + Linode . + + ), + }, + linode_config_update: { + notification: (e) => ( + <> + Config has been{' '} + updated on Linode . + + ), + }, + linode_create: { + failed: (e) => ( + <> + Linode {e.entity!.label} could not be{' '} + created. + + ), + finished: (e) => ( + <> + Linode has been{' '} + created. + + ), + scheduled: (e) => ( + <> + Linode {e.entity!.label} is scheduled for creation. + + ), + started: (e) => ( + <> + Linode {e.entity!.label} is being created. + + ), + }, + linode_delete: { + failed: (e) => ( + <> + Linode could not be{' '} + deleted. + + ), + finished: (e) => ( + <> + Linode {e.entity?.label} has been deleted. + + ), + notification: (e) => ( + <> + Linode {e.entity?.label} has been deleted. + + ), + scheduled: (e) => ( + <> + Linode is scheduled to be{' '} + deleted. + + ), + started: (e) => ( + <> + Linode is being{' '} + deleted. + + ), + }, + linode_deleteip: { + notification: (e) => ( + <> + An IP address has been removed from Linode{' '} + . + + ), + }, + linode_migrate: { + failed: (e) => ( + <> + Migration failed for Linode{' '} + . + + ), + finished: (e) => ( + <> + Linode has been{' '} + migrated. + + ), + scheduled: (e) => ( + <> + Linode is scheduled to be{' '} + migrated. + + ), + started: (e) => ( + <> + Linode is being{' '} + migrated. + + ), + }, + linode_migrate_datacenter: { + failed: (e) => ( + <> + Migration failed for Linode{' '} + . + + ), + finished: (e) => ( + <> + Linode has been{' '} + migrated. + + ), + scheduled: (e) => ( + <> + Linode is scheduled to be{' '} + migrated. + + ), + started: (e) => ( + <> + Linode is being{' '} + migrated. + + ), + }, + linode_migrate_datacenter_create: { + notification: (e) => ( + <> + Migration has been initiated for Linode{' '} + . + + ), + }, + linode_mutate: { + failed: (e) => ( + <> + Linode could not be{' '} + upgraded. + + ), + finished: (e) => ( + <> + Linode has been{' '} + upgraded. + + ), + notification: (e) => ( + <> + Linode is being{' '} + upgraded. + + ), + scheduled: (e) => ( + <> + Linode is scheduled to be{' '} + upgraded. + + ), + started: (e) => ( + <> + Linode is being{' '} + upgraded. + + ), + }, + linode_mutate_create: { + notification: (e) => ( + <> + A resize has been initiated for Linode{' '} + . + + ), + }, + linode_reboot: { + failed: (e) => ( + <> + Linode could not be{' '} + rebooted + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ), + finished: (e) => ( + <> + Linode has been{' '} + rebooted + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ), + scheduled: (e) => ( + <> + Linode is scheduled to be{' '} + rebooted + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ), + started: (e) => ( + <> + Linode is being{' '} + rebooted + {e.secondary_entity ? ( + <> + {' '} + with config + + ) : ( + '' + )} + . + + ), + }, + linode_rebuild: { + failed: (e) => ( + <> + Linode could not be{' '} + rebuilt. + + ), + finished: (e) => ( + <> + Linode has been{' '} + rebuilt. + + ), + scheduled: (e) => ( + <> + Linode is scheduled for{' '} + rebuild. + + ), + started: (e) => ( + <> + Linode is being{' '} + rebuilt. + + ), + }, + linode_resize: { + failed: (e) => ( + <> + Linode could not be{' '} + resized. + + ), + finished: (e) => ( + <> + Linode has been{' '} + resized. + + ), + notification: (e) => ( + <> + Linode is being{' '} + resized. + + ), + scheduled: (e) => ( + <> + Linode is scheduled for{' '} + resizing. + + ), + started: (e) => ( + <> + Linode is resizing. + + ), + }, + linode_resize_create: { + notification: (e) => ( + <> + A resize has been initiated for Linode{' '} + . + + ), + }, + linode_resize_warm_create: { + notification: (e) => ( + <> + A warm resize has been initiated for Linode{' '} + . + + ), + }, + linode_shutdown: { + failed: (e) => ( + <> + Linode could not be{' '} + shut down. + + ), + finished: (e) => ( + <> + Linode has been{' '} + shut down. + + ), + scheduled: (e) => ( + <> + Linode is scheduled for{' '} + shutdown. + + ), + started: (e) => ( + <> + Linode is{' '} + shutting down. + + ), + }, + linode_snapshot: { + failed: (e) => ( + <> + Snapshot backup failed on Linode{' '} + .{' '} + + Learn more about limits and considerations + + . + + ), + finished: (e) => ( + <> + A snapshot backup has been created for Linode{' '} + . + + ), + scheduled: (e) => ( + <> + Linode is scheduled for a snapshot + backup. + + ), + started: (e) => ( + <> + A snapshot backup is being created for Linode{' '} + . + + ), + }, + linode_update: { + notification: (e) => ( + <> + Linode has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/lish.tsx b/packages/manager/src/features/Events/factories/lish.tsx new file mode 100644 index 00000000000..6de7638b609 --- /dev/null +++ b/packages/manager/src/features/Events/factories/lish.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const lish: PartialEventMap<'lish'> = { + lish_boot: { + failed: (e) => ( + <> + Linode could not be{' '} + booted (Lish initiated boot). + + ), + finished: (e) => ( + <> + Linode has been{' '} + booted (Lish initiated boot). + + ), + scheduled: (e) => ( + <> + Linode is scheduled to{' '} + boot (Lish initiated boot). + + ), + started: (e) => ( + <> + Linode is being{' '} + booted (Lish initiated boot). + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/lke.tsx b/packages/manager/src/features/Events/factories/lke.tsx new file mode 100644 index 00000000000..209a1a8b5aa --- /dev/null +++ b/packages/manager/src/features/Events/factories/lke.tsx @@ -0,0 +1,131 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const lke: PartialEventMap<'lke'> = { + lke_cluster_create: { + notification: (e) => ( + <> + Kubernetes Cluster has been{' '} + created. + + ), + }, + lke_cluster_delete: { + notification: (e) => ( + <> + Kubernetes Cluster {e.entity?.label} has been deleted. + + ), + }, + lke_cluster_recycle: { + notification: (e) => ( + <> + Kubernetes Cluster has been{' '} + recycled. + + ), + }, + lke_cluster_regenerate: { + notification: (e) => ( + <> + Kubernetes Cluster has been{' '} + regenerated. + + ), + }, + lke_cluster_update: { + notification: (e) => ( + <> + Kubernetes Cluster has been{' '} + updated. + + ), + }, + lke_control_plane_acl_create: { + notification: (e) => ( + <> + The IP ACL for Kubernetes Cluster {' '} + has been created. + + ), + }, + lke_control_plane_acl_delete: { + notification: (e) => ( + <> + The IP ACL for Kubernetes Cluster {' '} + has been disabled. + + ), + }, + lke_control_plane_acl_update: { + notification: (e) => ( + <> + The IP ACL for Kubernetes Cluster {' '} + has been updated. + + ), + }, + lke_kubeconfig_regenerate: { + notification: (e) => ( + <> + The kubeconfig for Kubernetes Cluster{' '} + has been{' '} + regenerated. + + ), + }, + lke_node_create: { + // This event is a special case; a notification means the node creation failed. + // The entity is the node pool, but entity.label contains the cluster's label. + notification: (e) => ( + <> + Kubernetes Cluster node could not be created + {e.entity?.label ? ' on ' : ''} + . + + ), + }, + lke_node_recycle: { + notification: (e) => ( + <> + The node for Kubernetes Cluster has + been recycled. + + ), + }, + lke_pool_create: { + notification: (e) => ( + <> + A Node Pool for Kubernetes Cluster {' '} + has been created. + + ), + }, + lke_pool_delete: { + notification: (e) => ( + <> + A Node Pool for Kubernetes Cluster {' '} + has been deleted. + + ), + }, + lke_pool_recycle: { + notification: (e) => ( + <> + A Node Pool for Kubernetes Cluster {' '} + has been recycled. + + ), + }, + lke_token_rotate: { + notification: (e) => ( + <> + The token for Kubernetes Cluster has + been rotated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/longviewclient.tsx b/packages/manager/src/features/Events/factories/longviewclient.tsx new file mode 100644 index 00000000000..f2d85f69b4a --- /dev/null +++ b/packages/manager/src/features/Events/factories/longviewclient.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const longviewclient: PartialEventMap<'longviewclient'> = { + longviewclient_create: { + notification: (e) => ( + <> + Longview Client has been{' '} + created. + + ), + }, + longviewclient_delete: { + notification: (e) => ( + <> + Longview Client {e.entity?.label} has been deleted. + + ), + }, + longviewclient_update: { + notification: (e) => ( + <> + Longview Client has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/managed.tsx b/packages/manager/src/features/Events/factories/managed.tsx new file mode 100644 index 00000000000..0ee8ecd21d7 --- /dev/null +++ b/packages/manager/src/features/Events/factories/managed.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const managed: PartialEventMap<'managed'> = { + managed_enabled: { + notification: () => ( + <> + Managed has been activated on your account. + + ), + }, + managed_service_create: { + notification: (e) => ( + <> + Managed service has been{' '} + created. + + ), + }, + managed_service_delete: { + notification: (e) => ( + <> + Managed service {e.entity?.label} has been deleted. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/nodebalancer.tsx b/packages/manager/src/features/Events/factories/nodebalancer.tsx new file mode 100644 index 00000000000..5d19a0e7a41 --- /dev/null +++ b/packages/manager/src/features/Events/factories/nodebalancer.tsx @@ -0,0 +1,79 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const nodebalancer: PartialEventMap<'nodebalancer'> = { + nodebalancer_config_create: { + notification: (e) => ( + <> + A config on NodeBalancer has been{' '} + created. + + ), + }, + nodebalancer_config_delete: { + notification: (e) => ( + <> + A config on NodeBalancer has been{' '} + deleted. + + ), + }, + nodebalancer_config_update: { + notification: (e) => ( + <> + A config on NodeBalancer has been{' '} + updated. + + ), + }, + nodebalancer_create: { + notification: (e) => ( + <> + NodeBalancer has been{' '} + created. + + ), + }, + nodebalancer_delete: { + notification: (e) => ( + <> + NodeBalancer {e.entity?.label} has been deleted. + + ), + }, + nodebalancer_node_create: { + notification: (e) => ( + <> + A node on NodeBalancer has been{' '} + created. + + ), + }, + nodebalancer_node_delete: { + notification: (e) => ( + <> + A node on NodeBalancer has been{' '} + deleted. + + ), + }, + nodebalancer_node_update: { + notification: (e) => ( + <> + A node on NodeBalancer has been{' '} + updated. + + ), + }, + nodebalancer_update: { + notification: (e) => ( + <> + NodeBalancer has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/oAuth.tsx b/packages/manager/src/features/Events/factories/oAuth.tsx new file mode 100644 index 00000000000..a50e28de7f8 --- /dev/null +++ b/packages/manager/src/features/Events/factories/oAuth.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const oAuth: PartialEventMap<'oauth'> = { + oauth_client_create: { + notification: (e) => ( + <> + OAuth App has been{' '} + created. + + ), + }, + oauth_client_delete: { + notification: (e) => ( + <> + OAuth App {e.entity?.label} has been deleted. + + ), + }, + oauth_client_secret_reset: { + notification: (e) => ( + <> + Secret for OAuth App has been{' '} + reset. + + ), + }, + oauth_client_update: { + notification: (e) => ( + <> + OAuth App has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/obj.tsx b/packages/manager/src/features/Events/factories/obj.tsx new file mode 100644 index 00000000000..973d28f30f3 --- /dev/null +++ b/packages/manager/src/features/Events/factories/obj.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const obj: PartialEventMap<'obj'> = { + obj_access_key_create: { + notification: (e) => ( + <> + Access Key has been{' '} + created. + + ), + }, + obj_access_key_delete: { + notification: (e) => ( + <> + Access Key {e.entity?.label} has been deleted. + + ), + }, + obj_access_key_update: { + notification: (e) => ( + <> + Access Key has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/password.tsx b/packages/manager/src/features/Events/factories/password.tsx new file mode 100644 index 00000000000..f5cb306445c --- /dev/null +++ b/packages/manager/src/features/Events/factories/password.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const password: PartialEventMap<'password'> = { + password_reset: { + failed: (e) => ( + <> + Password for Linode could{' '} + not be reset. + + ), + finished: (e) => ( + <> + Password for Linode has been{' '} + reset. + + ), + + scheduled: (e) => ( + <> + Password for Linode has been{' '} + scheduled. + + ), + started: (e) => ( + <> + Password for Linode is being{' '} + reset. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/payment.tsx b/packages/manager/src/features/Events/factories/payment.tsx new file mode 100644 index 00000000000..cc3b4cb497c --- /dev/null +++ b/packages/manager/src/features/Events/factories/payment.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const payment: PartialEventMap<'payment'> = { + payment_method_add: { + notification: () => ( + <> + A payment method has been added. + + ), + }, + payment_submitted: { + notification: () => ( + <> + A payment has been submitted. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/placement.tsx b/packages/manager/src/features/Events/factories/placement.tsx new file mode 100644 index 00000000000..5d71894f979 --- /dev/null +++ b/packages/manager/src/features/Events/factories/placement.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const placement: PartialEventMap<'placement'> = { + placement_group_assign: { + notification: (e) => ( + <> + Linode has been{' '} + assigned to Placement Group{' '} + . + + ), + }, + placement_group_became_compliant: { + notification: (e) => ( + <> + Placement Group has become{' '} + compliant. + + ), + }, + placement_group_became_non_compliant: { + notification: (e) => ( + <> + Placement Group has become{' '} + non-compliant. + + ), + }, + placement_group_create: { + notification: (e) => ( + <> + Placement Group has been{' '} + created. + + ), + }, + placement_group_delete: { + notification: (e) => ( + <> + Placement Group {e.entity?.label} has been deleted. + + ), + }, + placement_group_unassign: { + notification: (e) => ( + <> + Linode has been{' '} + unassigned from Placement Group{' '} + . + + ), + }, + placement_group_update: { + notification: (e) => ( + <> + Placement Group has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/profile.tsx b/packages/manager/src/features/Events/factories/profile.tsx new file mode 100644 index 00000000000..84940972920 --- /dev/null +++ b/packages/manager/src/features/Events/factories/profile.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const profile: PartialEventMap<'profile'> = { + profile_update: { + notification: (e) => ( + <> + Your profile has been updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/reserved.tsx b/packages/manager/src/features/Events/factories/reserved.tsx new file mode 100644 index 00000000000..0160daff657 --- /dev/null +++ b/packages/manager/src/features/Events/factories/reserved.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const reserved: PartialEventMap<'reserved'> = { + reserved_ip_assign: { + notification: () => ( + <> + A reserved IP address has been assigned to your + account. + + ), + }, + reserved_ip_create: { + notification: () => ( + <> + A reserved IP address has been created on your + account. + + ), + }, + reserved_ip_delete: { + notification: () => ( + <> + A reserved IP address has been deleted from your + account. + + ), + }, + reserved_ip_unassign: { + notification: () => ( + <> + A reserved IP address has been unassigned from your + account. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/stackscript.tsx b/packages/manager/src/features/Events/factories/stackscript.tsx new file mode 100644 index 00000000000..a8bdfee4b52 --- /dev/null +++ b/packages/manager/src/features/Events/factories/stackscript.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const stackscript: PartialEventMap<'stackscript'> = { + stackscript_create: { + notification: (e) => ( + <> + StackScript has been{' '} + created. + + ), + }, + stackscript_delete: { + notification: (e) => ( + <> + StackScript {e.entity?.label} has been deleted. + + ), + }, + stackscript_publicize: { + notification: (e) => ( + <> + StackScript has been{' '} + made public. + + ), + }, + stackscript_revise: { + notification: (e) => ( + <> + StackScript has been{' '} + revised. + + ), + }, + stackscript_update: { + notification: (e) => ( + <> + StackScript has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/subnet.tsx b/packages/manager/src/features/Events/factories/subnet.tsx new file mode 100644 index 00000000000..a9d1c3ab13f --- /dev/null +++ b/packages/manager/src/features/Events/factories/subnet.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const subnet: PartialEventMap<'subnet'> = { + subnet_create: { + notification: (e) => ( + <> + Subnet has been{' '} + created in VPC{' '} + . + + ), + }, + subnet_delete: { + notification: (e) => ( + <> + Subnet {e.entity?.label} has been deleted in VPC{' '} + . + + ), + }, + subnet_update: { + notification: (e) => ( + <> + Subnet in VPC{' '} + has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/tag.tsx b/packages/manager/src/features/Events/factories/tag.tsx new file mode 100644 index 00000000000..534e25e7434 --- /dev/null +++ b/packages/manager/src/features/Events/factories/tag.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const tag: PartialEventMap<'tag'> = { + tag_create: { + notification: (e) => ( + <> + Tag has been{' '} + created. + + ), + }, + tag_delete: { + notification: (e) => ( + <> + Tag {e.entity?.label} has been deleted. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/tfa.tsx b/packages/manager/src/features/Events/factories/tfa.tsx new file mode 100644 index 00000000000..3a5da658062 --- /dev/null +++ b/packages/manager/src/features/Events/factories/tfa.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; + +import type { PartialEventMap } from '../types'; + +export const tfa: PartialEventMap<'tfa'> = { + tfa_disabled: { + notification: () => ( + <> + Two-factor authentication has been disabled. + + ), + }, + tfa_enabled: { + notification: () => ( + <> + Two-factor authentication has been enabled. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/ticket.tsx b/packages/manager/src/features/Events/factories/ticket.tsx new file mode 100644 index 00000000000..e28ba8b25db --- /dev/null +++ b/packages/manager/src/features/Events/factories/ticket.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const ticket: PartialEventMap<'ticket'> = { + ticket_attachment_upload: { + notification: (e) => ( + <> + File has been successfully uploaded to support ticket + " + + ". + + ), + }, + ticket_create: { + notification: (e) => ( + <> + New support ticket " + + " has been created. + + ), + }, + ticket_update: { + notification: (e) => ( + <> + Support ticket " + + " has been updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/token.tsx b/packages/manager/src/features/Events/factories/token.tsx new file mode 100644 index 00000000000..ed35e289395 --- /dev/null +++ b/packages/manager/src/features/Events/factories/token.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const token: PartialEventMap<'token'> = { + token_create: { + notification: (e) => ( + <> + Token has been{' '} + created. + + ), + }, + token_delete: { + notification: (e) => ( + <> + Token {e.entity?.label} has been revoked. + + ), + }, + token_update: { + notification: (e) => ( + <> + Token has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/user.tsx b/packages/manager/src/features/Events/factories/user.tsx new file mode 100644 index 00000000000..e5e5323662c --- /dev/null +++ b/packages/manager/src/features/Events/factories/user.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const user: PartialEventMap<'user'> = { + user_create: { + notification: (e) => ( + <> + User has been{' '} + created. + + ), + }, + user_delete: { + notification: (e) => ( + <> + User {e.entity?.label} has been deleted. + + ), + }, + user_ssh_key_add: { + notification: () => ( + <> + An SSH key has been added to your profile. + + ), + }, + user_ssh_key_delete: { + notification: () => ( + <> + An SSH key has been deleted from your profile. + + ), + }, + user_ssh_key_update: { + notification: (e) => ( + <> + SSH key has been{' '} + updated in your profile. + + ), + }, + user_update: { + notification: (e) => ( + <> + User has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/volume.tsx b/packages/manager/src/features/Events/factories/volume.tsx new file mode 100644 index 00000000000..5991cb0573c --- /dev/null +++ b/packages/manager/src/features/Events/factories/volume.tsx @@ -0,0 +1,196 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const volume: PartialEventMap<'volume'> = { + volume_attach: { + failed: (e) => ( + <> + Volume could not be{' '} + attached to Linode{' '} + . + + ), + finished: (e) => ( + <> + Volume has been{' '} + attached to Linode{' '} + . + + ), + notification: (e) => ( + <> + Volume has been{' '} + attached to Linode{' '} + . + + ), + scheduled: (e) => ( + <> + Volume is scheduled to be{' '} + attached to Linode{' '} + . + + ), + started: (e) => ( + <> + Volume is being{' '} + attached to Linode{' '} + . + + ), + }, + volume_clone: { + notification: (e) => ( + <> + Volume has been{' '} + cloned. + + ), + }, + volume_create: { + failed: (e) => ( + <> + Volume could not be{' '} + created. + + ), + finished: (e) => ( + <> + Volume has been{' '} + created. + + ), + notification: (e) => ( + <> + Volume has been{' '} + created. + + ), + scheduled: (e) => ( + <> + Volume is scheduled to be{' '} + created. + + ), + started: (e) => ( + <> + Volume is being{' '} + created. + + ), + }, + volume_delete: { + failed: (e) => ( + <> + Volume could not be{' '} + deleted. + + ), + finished: (e) => ( + <> + Volume {e.entity?.label} has been deleted. + + ), + notification: (e) => ( + <> + Volume {e.entity?.label} has been deleted. + + ), + scheduled: (e) => ( + <> + Volume is scheduled to be{' '} + deleted. + + ), + started: (e) => ( + <> + Volume is being{' '} + deleted. + + ), + }, + volume_detach: { + failed: (e) => ( + <> + Volume could not be{' '} + detached from Linode{' '} + . + + ), + finished: (e) => ( + <> + Volume has been{' '} + detached from Linode{' '} + . + + ), + notification: (e) => ( + <> + Volume has been{' '} + detached from Linode{' '} + . + + ), + scheduled: (e) => ( + <> + Volume is scheduled to be{' '} + detached from Linode{' '} + . + + ), + started: (e) => ( + <> + Volume is being{' '} + detached from Linode{' '} + . + + ), + }, + volume_migrate: { + failed: (e) => ( + <> + Volume could not be{' '} + migrated to NVMe. + + ), + finished: (e) => ( + <> + Volume has been{' '} + migrated to NVMe. + + ), + started: (e) => ( + <> + Volume is being{' '} + migrated to NVMe. + + ), + }, + volume_migrate_scheduled: { + scheduled: (e) => ( + <> + Volume is scheduled to be{' '} + migrated to NVMe. + + ), + }, + volume_resize: { + notification: (e) => ( + <> + Volume has been{' '} + resized. + + ), + }, + volume_update: { + notification: (e) => ( + <> + Volume has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factories/vpc.tsx b/packages/manager/src/features/Events/factories/vpc.tsx new file mode 100644 index 00000000000..1a539cbb100 --- /dev/null +++ b/packages/manager/src/features/Events/factories/vpc.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; + +import { EventLink } from '../EventLink'; + +import type { PartialEventMap } from '../types'; + +export const vpc: PartialEventMap<'vpc'> = { + vpc_create: { + notification: (e) => ( + <> + VPC has been{' '} + created. + + ), + }, + vpc_delete: { + notification: (e) => ( + <> + VPC {e.entity?.label} has been deleted. + + ), + }, + vpc_update: { + notification: (e) => ( + <> + VPC has been{' '} + updated. + + ), + }, +}; diff --git a/packages/manager/src/features/Events/factory.test.tsx b/packages/manager/src/features/Events/factory.test.tsx new file mode 100644 index 00000000000..f3fb6abef78 --- /dev/null +++ b/packages/manager/src/features/Events/factory.test.tsx @@ -0,0 +1,14 @@ +import { EventActionKeys } from '@linode/api-v4'; + +import { eventMessages } from './factory'; + +/** + * This test ensures any event message added to our types has a corresponding message in our factory. + */ +describe('eventMessages', () => { + it('should have a message for each EventAction', () => { + EventActionKeys.forEach((action) => { + expect(eventMessages).toHaveProperty(action); + }); + }); +}); diff --git a/packages/manager/src/features/Events/factory.tsx b/packages/manager/src/features/Events/factory.tsx new file mode 100644 index 00000000000..7a1dcddde67 --- /dev/null +++ b/packages/manager/src/features/Events/factory.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; + +import { Typography } from 'src/components/Typography'; + +import * as factories from './factories'; + +import type { EventMap, OptionalEventMap } from './types'; +import type { Event } from '@linode/api-v4'; + +/** + * The event Message Mapper + * + * It aggregates all the event messages from the factories and wraps them with Typography. + * The typography intentionally wraps the message in a span to prevent nested paragraphs while adhering to the design system's typography. + */ + +const wrapWithTypography = ( + Component: (e: Partial) => JSX.Element | string +) => { + return (e: Partial) => { + const result = Component(e); + return {result}; + }; +}; + +export const withTypography = (eventMap: EventMap): OptionalEventMap => { + return Object.fromEntries( + Object.entries(eventMap).map(([action, statuses]) => [ + action, + Object.fromEntries( + Object.entries(statuses).map(([status, func]) => [ + status, + wrapWithTypography(func), + ]) + ), + ]) + ); +}; + +export const eventMessages: EventMap = Object.keys(factories).reduce( + (acc, factoryName) => ({ + ...acc, + ...withTypography(factories[factoryName]), + }), + {} as EventMap +); diff --git a/packages/manager/src/features/Events/types.ts b/packages/manager/src/features/Events/types.ts new file mode 100644 index 00000000000..528b5212e4d --- /dev/null +++ b/packages/manager/src/features/Events/types.ts @@ -0,0 +1,21 @@ +import type { Event, EventAction, EventStatus } from '@linode/api-v4'; + +type PrefixByUnderscore = T extends `${infer s}_${string}` ? s : never; + +type EventActionPrefixes = PrefixByUnderscore; + +export type OptionalEventMap = { + [K in EventAction]?: EventMessage; +}; + +export type EventMessage = { + [S in EventStatus]?: (e: Event) => JSX.Element | string; +}; + +export type PartialEventMap = { + [K in Extract]: EventMessage; +}; + +export type EventMap = { + [K in EventAction]: EventMessage; +}; diff --git a/packages/manager/src/features/Events/utils.test.tsx b/packages/manager/src/features/Events/utils.test.tsx new file mode 100644 index 00000000000..d08e630e415 --- /dev/null +++ b/packages/manager/src/features/Events/utils.test.tsx @@ -0,0 +1,83 @@ +import { Event } from '@linode/api-v4'; + +import { eventFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { getEventMessage } from './utils'; + +describe('getEventMessage', () => { + const mockEvent1: Event = eventFactory.build({ + action: 'linode_create', + entity: { + id: 123, + label: 'test-linode', + }, + status: 'finished', + }); + + const mockEvent2: Event = eventFactory.build({ + action: 'linode_config_create', + entity: { + id: 123, + label: 'test-linode', + type: 'linode', + }, + secondary_entity: { + id: 456, + label: 'test-config', + type: 'linode', + }, + status: 'notification', + }); + + it('returns the correct message for a given event', () => { + const message = getEventMessage(mockEvent1); + + const { container, getByRole } = renderWithTheme(message); + + expect(container.querySelector('span')).toHaveTextContent( + /Linode test-linode has been created./i + ); + expect(container.querySelector('strong')).toHaveTextContent('created'); + expect(getByRole('link')).toHaveAttribute('href', '/linodes/123'); + }); + + it('returns the correct message for a given event with a secondary entity', () => { + const message = getEventMessage(mockEvent2); + + const { container, getAllByRole } = renderWithTheme(message); + + expect(container.querySelector('span')).toHaveTextContent( + /Config test-config has been created on Linode test-linode./i + ); + expect(container.querySelector('strong')).toHaveTextContent('created'); + + const links = getAllByRole('link'); + expect(links.length).toBe(2); + expect(links[0]).toHaveAttribute('href', '/linodes/456'); + expect(links[1]).toHaveAttribute('href', '/linodes/123'); + }); + + it('returns the correct message for a manual input event', () => { + const message = getEventMessage({ + action: 'linode_create', + entity: { + id: 123, + label: 'test-linode', + type: 'linode', + }, + status: 'failed', + }); + + const { container } = renderWithTheme(message); + + expect(container.querySelector('span')).toHaveTextContent( + /Linode test-linode could not be created./i + ); + + const boldedWords = container.querySelectorAll('strong'); + expect(boldedWords).toHaveLength(2); + expect(boldedWords[0]).toHaveTextContent('not'); + expect(boldedWords[1]).toHaveTextContent('created'); + }); +}); diff --git a/packages/manager/src/features/Events/utils.tsx b/packages/manager/src/features/Events/utils.tsx new file mode 100644 index 00000000000..291599ab218 --- /dev/null +++ b/packages/manager/src/features/Events/utils.tsx @@ -0,0 +1,40 @@ +import { eventMessages } from './factory'; + +import type { Event } from '@linode/api-v4'; + +type EventMessageManualInput = { + action: Event['action']; + entity?: Partial; + secondary_entity?: Partial; + status: Event['status']; +}; + +/** + * The event Message Getter + * Intentionally avoiding parsing and formatting, and should remain as such. + * + * Defining two function signatures for getEventMessage: + * - A function that takes a full Event object (event page and notification center) + * - A function that takes an object with action, status, entity, and secondary_entity (getting a message for a snackbar for instance, where we manually pass the action & status) + * + * Using typescript overloads allows for both Event and EventMessageInput types. + * + * We don't include defaulting to the API message response here because: + * - we want to control the message output (our types require us to define one) and rather show nothing than a broken message. + * - the API message is empty 99% of the time and when present, isn't meant to be displayed as a full message, rather a part of it. (ex: `domain_record_create`) + */ +export function getEventMessage(event: Event): JSX.Element | null | string; +export function getEventMessage( + event: EventMessageManualInput +): JSX.Element | null | string; +export function getEventMessage( + event: Event | EventMessageManualInput +): JSX.Element | null | string { + if (!event?.action || !event?.status) { + return null; + } + + const message = eventMessages[event?.action]?.[event.status]; + + return message ? message(event as Event) : null; +} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/ActivityRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/ActivityRow.tsx deleted file mode 100644 index 36f68f548a4..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/ActivityRow.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Event } from '@linode/api-v4/lib/account'; -import Grid from '@mui/material/Unstable_Grid2'; -import { styled } from '@mui/material/styles'; -import * as React from 'react'; - -import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; -import { Typography } from 'src/components/Typography'; -import { generateEventMessage } from 'src/features/Events/eventMessageGenerator'; -import { formatEventSeconds } from 'src/utilities/minute-conversion'; - -interface Props { - event: Event; -} - -export const ActivityRow = (props: Props) => { - const { event } = props; - - const message = generateEventMessage(event); - - // There is currently an API bug where host_reboot event durations are not - // reported correctly. This patch simply hides the duration. @todo remove this - // check when the API bug is fixed. - const duration = - event.action === 'host_reboot' ? '' : formatEventSeconds(event.duration); - - if (!message) { - return null; - } - - return ( - - - - {message} {duration && `(${duration})`} - - - - - - - ); -}; - -const StyledGrid = styled(Grid, { label: 'StyledGrid' })(({ theme }) => ({ - borderBottom: `1px solid ${theme.palette.divider}`, - margin: 0, - padding: theme.spacing(1), - width: '100%', -})); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx index 90eef3a557e..1c9957f1f55 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx @@ -5,6 +5,8 @@ import { Box } from 'src/components/Box'; import { Divider } from 'src/components/Divider'; import { HighlightedMarkdown } from 'src/components/HighlightedMarkdown/HighlightedMarkdown'; import { Typography } from 'src/components/Typography'; +import { getEventMessage } from 'src/features/Events/utils'; +import { useFlags } from 'src/hooks/useFlags'; import { getEventTimestamp } from 'src/utilities/eventUtils'; import { getAllowedHTMLTags } from 'src/utilities/sanitizeHTML.utils'; @@ -21,9 +23,11 @@ interface RenderEventProps { } export const RenderEvent = React.memo((props: RenderEventProps) => { + const flags = useFlags(); const { classes, cx } = useRenderEventStyles(); const { event } = props; const { message } = useEventInfo(event); + const messageV2 = getEventMessage(event); const unseenEventClass = cx({ [classes.unseenEvent]: !event.seen }); @@ -43,6 +47,37 @@ export const RenderEvent = React.memo((props: RenderEventProps) => { ); + if (flags.eventMessagesV2) { + return ( + /** + * Some event types may not be handled by our system (or new types or new ones may be added that we haven't caught yet). + * Filter these out so we don't display blank messages to the user. + * We have sentry events being logged for these cases, so we can always go back and add support for them as soon as aware. + */ + messageV2 ? ( + <> + + + + {messageV2} + + {getEventTimestamp(event).toRelative()} + {event.username && ` | ${event.username}`} + + + + + + ) : null + ); + } + return ( <> diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx index f265cc41834..e8f38ccfcec 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx @@ -58,7 +58,10 @@ export const RenderProgressEvent = (props: Props) => { return ( <> - + (({ theme, ...props }) => ({ '& p': { - color: theme.textColors.headlineStatic, lineHeight: '1.25rem', }, display: 'flex', From ae81ee3f377f8ce7abaf89d9ef59af0f63abc630 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Tue, 4 Jun 2024 08:01:24 -0700 Subject: [PATCH 049/163] fix: [M3-7718] - Stale assigned Firewall data displaying on Linode and NodeBalancer details pages (#10534) * Invalidate FWNB devices query when FW updates * Update query key naming for consistency * Added changeset: Stale assigned Firewall data displaying on Linode and NodeBalancer details pages --- .../pr-10534-fixed-1717169773875.md | 5 ++++ .../Devices/RemoveDeviceDialog.tsx | 12 ++++----- .../Rules/FirewallRulesLanding.tsx | 25 +++++++++++++++++-- .../FirewallLanding/CreateFirewallDrawer.tsx | 4 +-- .../FirewallLanding/FirewallDialog.tsx | 24 ++++++++++-------- 5 files changed, 49 insertions(+), 21 deletions(-) create mode 100644 packages/manager/.changeset/pr-10534-fixed-1717169773875.md diff --git a/packages/manager/.changeset/pr-10534-fixed-1717169773875.md b/packages/manager/.changeset/pr-10534-fixed-1717169773875.md new file mode 100644 index 00000000000..acc151e9106 --- /dev/null +++ b/packages/manager/.changeset/pr-10534-fixed-1717169773875.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Stale assigned Firewall data displaying on Linode and NodeBalancer details pages ([#10534](https://github.com/linode/manager/pull/10534)) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx index 39cb445108b..0d9c3cd2d7a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx @@ -1,15 +1,15 @@ import { FirewallDevice } from '@linode/api-v4'; +import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useQueryClient } from '@tanstack/react-query'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Typography } from 'src/components/Typography'; -import { queryKey as firewallQueryKey } from 'src/queries/firewalls'; import { useRemoveFirewallDeviceMutation } from 'src/queries/firewalls'; +import { queryKey as firewallQueryKey } from 'src/queries/firewalls'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; -import { queryKey as nodeBalancerQueryKey } from 'src/queries/nodebalancers'; +import { queryKey as nodebalancersQueryKey } from 'src/queries/nodebalancers'; export interface Props { device: FirewallDevice | undefined; @@ -48,12 +48,12 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { enqueueSnackbar(error[0].reason, { variant: 'error' }); } - const querykey = - deviceType === 'linode' ? linodesQueryKey : nodeBalancerQueryKey; + const queryKey = + deviceType === 'linode' ? linodesQueryKey : nodebalancersQueryKey; // Since the linode was removed as a device, invalidate the linode-specific firewall query queryClient.invalidateQueries([ - querykey, + queryKey, deviceType, device?.entity.id, 'firewalls', diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx index 28782cb1f99..5d978b108fc 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx @@ -1,4 +1,5 @@ import { styled } from '@mui/material/styles'; +import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -7,11 +8,15 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { Notice } from 'src/components/Notice/Notice'; import { Prompt } from 'src/components/Prompt/Prompt'; import { Typography } from 'src/components/Typography'; -import { useUpdateFirewallRulesMutation } from 'src/queries/firewalls'; +import { + useAllFirewallDevicesQuery, + useUpdateFirewallRulesMutation, +} from 'src/queries/firewalls'; +import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; +import { queryKey as nodebalancersQueryKey } from 'src/queries/nodebalancers'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { FirewallRuleDrawer } from './FirewallRuleDrawer'; -import { FirewallRuleTable } from './FirewallRuleTable'; import { hasModified as _hasModified, curriedFirewallRuleEditorReducer, @@ -20,6 +25,7 @@ import { prepareRules, stripExtendedFields, } from './firewallRuleEditor'; +import { FirewallRuleTable } from './FirewallRuleTable'; import { parseFirewallRuleError } from './shared'; import type { FirewallRuleDrawerMode } from './FirewallRuleDrawer.types'; @@ -51,6 +57,8 @@ export const FirewallRulesLanding = React.memo((props: Props) => { const { mutateAsync: updateFirewallRules } = useUpdateFirewallRulesMutation( firewallID ); + const { data: devices } = useAllFirewallDevicesQuery(firewallID); + const queryClient = useQueryClient(); const { enqueueSnackbar } = useSnackbar(); @@ -193,6 +201,19 @@ export const FirewallRulesLanding = React.memo((props: Props) => { updateFirewallRules(finalRules) .then((_rules) => { setSubmitting(false); + // Invalidate Firewalls assigned to NodeBalancers and Linodes. + // eslint-disable-next-line no-unused-expressions + devices?.forEach((device) => + queryClient.invalidateQueries([ + device.entity.type === 'linode' + ? linodesQueryKey + : nodebalancersQueryKey, + device.entity.type, + device.entity.id, + 'firewalls', + ]) + ); + // Reset editor state. inboundDispatch({ rules: _rules.inbound ?? [], type: 'RESET' }); outboundDispatch({ rules: _rules.outbound ?? [], type: 'RESET' }); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx index b60668ff09a..24b0e3efdb5 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx @@ -33,7 +33,7 @@ import { useCreateFirewall, } from 'src/queries/firewalls'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; -import { queryKey as nodebalancerQueryKey } from 'src/queries/nodebalancers'; +import { queryKey as nodebalancersQueryKey } from 'src/queries/nodebalancers'; import { useGrants } from 'src/queries/profile'; import { sendLinodeCreateFormStepEvent } from 'src/utilities/analytics/formEventAnalytics'; import { getErrorMap } from 'src/utilities/errorUtils'; @@ -153,7 +153,7 @@ export const CreateFirewallDrawer = React.memo( if (payload.devices?.nodebalancers) { payload.devices.nodebalancers.forEach((nodebalancerId) => { queryClient.invalidateQueries([ - nodebalancerQueryKey, + nodebalancersQueryKey, 'nodebalancer', nodebalancerId, 'firewalls', diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx index a8af6709fbc..0fc5940e071 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx @@ -8,7 +8,7 @@ import { useDeleteFirewall, useMutateFirewall } from 'src/queries/firewalls'; import { queryKey as firewallQueryKey } from 'src/queries/firewalls'; import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; -import { queryKey as nodebalancerQueryKey } from 'src/queries/nodebalancers'; +import { queryKey as nodebalancersQueryKey } from 'src/queries/nodebalancers'; import { capitalize } from 'src/utilities/capitalize'; export type Mode = 'delete' | 'disable' | 'enable'; @@ -66,17 +66,19 @@ export const FirewallDialog = React.memo((props: Props) => { const onSubmit = async () => { await requestMap[mode](); + // Invalidate Firewalls assigned to NodeBalancers and Linodes when Firewall is enabled, disabled, or deleted. + // eslint-disable-next-line no-unused-expressions + devices?.forEach((device) => { + const deviceType = device.entity.type; + queryClient.invalidateQueries([ + deviceType === 'linode' ? linodesQueryKey : nodebalancersQueryKey, + deviceType, + device.entity.id, + 'firewalls', + ]); + }); if (mode === 'delete') { - devices?.forEach((device) => { - const deviceType = device.entity.type; - queryClient.invalidateQueries([ - deviceType === 'linode' ? linodesQueryKey : nodebalancerQueryKey, - deviceType, - device.entity.id, - 'firewalls', - ]); - queryClient.invalidateQueries([firewallQueryKey]); - }); + queryClient.invalidateQueries([firewallQueryKey]); } enqueueSnackbar(`Firewall ${label} successfully ${mode}d`, { variant: 'success', From e0922481a8159569d2e66e7384b4bfc3d141bd69 Mon Sep 17 00:00:00 2001 From: Hussain Khalil <122488130+hkhalil-akamai@users.noreply.github.com> Date: Tue, 4 Jun 2024 14:39:14 -0400 Subject: [PATCH 050/163] upcoming: [M3-8014] - Update APIv4 and Validation packages based on Image Service spec (#10541) * Update APIv4 and validation based on Image Service API spec * Added changeset: Add `regions` and `total_size` fields to `imageFactory` * Added changeset: Update images endpoints to reflect the image service API spec * Added changeset: `updateImageRegionsSchema` * Add JSDoc comments for Image fields --- ...r-10541-upcoming-features-1717446459710.md | 5 + packages/api-v4/src/images/images.ts | 19 ++++ packages/api-v4/src/images/types.ts | 93 ++++++++++++++++++- .../pr-10541-changed-1717446372854.md | 5 + packages/manager/src/factories/images.ts | 2 + .../pr-10541-added-1717446497849.md | 5 + packages/validation/src/images.schema.ts | 8 +- 7 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10541-upcoming-features-1717446459710.md create mode 100644 packages/manager/.changeset/pr-10541-changed-1717446372854.md create mode 100644 packages/validation/.changeset/pr-10541-added-1717446497849.md diff --git a/packages/api-v4/.changeset/pr-10541-upcoming-features-1717446459710.md b/packages/api-v4/.changeset/pr-10541-upcoming-features-1717446459710.md new file mode 100644 index 00000000000..b5e7a41a687 --- /dev/null +++ b/packages/api-v4/.changeset/pr-10541-upcoming-features-1717446459710.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Update images endpoints to reflect the image service API spec ([#10541](https://github.com/linode/manager/pull/10541)) diff --git a/packages/api-v4/src/images/images.ts b/packages/api-v4/src/images/images.ts index 53b3d3d63e0..c625a158a85 100644 --- a/packages/api-v4/src/images/images.ts +++ b/packages/api-v4/src/images/images.ts @@ -1,5 +1,6 @@ import { createImageSchema, + updateImageRegionsSchema, updateImageSchema, uploadImageSchema, } from '@linode/validation/lib/images.schema'; @@ -107,3 +108,21 @@ export const uploadImage = (data: ImageUploadPayload) => { setData(data, uploadImageSchema) ); }; + +/** + * Selects the regions to which this image will be replicated. + * + * @param imageId { string } ID of the Image to look up. + * @param regions { string[] } ID of regions to replicate to. Must contain at least one valid region. + */ +export const updateImageRegions = (imageId: string, regions: string[]) => { + const data = { + regions, + }; + + return Request( + setURL(`${API_ROOT}/images/${encodeURIComponent(imageId)}/regions`), + setMethod('POST'), + setData(data, updateImageRegionsSchema) + ); +}; diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index eabad2806a4..dd034eda126 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -4,24 +4,113 @@ export type ImageStatus = | 'deleted' | 'pending_upload'; -type ImageCapabilities = 'cloud-init'; +type ImageCapabilities = 'cloud-init' | 'distributed-images'; + +type ImageType = 'manual' | 'automatic'; + +type ImageRegionStatus = + | 'creating' + | 'pending' + | 'available' + | 'pending deletion' + | 'pending replication' + | 'replicating' + | 'timedout'; + +export interface ImageRegion { + region: string; + status: ImageRegionStatus; +} export interface Image { + /** + * An optional timestamp of this image's planned end-of-life. + */ eol: string | null; + + /** + * The unique ID of the this image. + */ id: string; + + /** + * A short description of this image. + */ label: string; + + /** + * A detailed description of this image. + */ description: string | null; + + /** + * The timestamp of when this image was created. + */ created: string; + + /** + * The timestamp of when this image was last updated. + */ updated: string; - type: string; + + /** + * Indicates the method of this image's creation. + */ + type: ImageType; + + /** + * Whether this image is marked for public distribution. + */ is_public: boolean; + + /** + * The minimum size in MB needed to deploy this image. + */ size: number; + + /** + * The total storage consumed by this image across its regions. + */ + total_size: number; + + /** + * The name of the user who created this image or 'linode' for public images. + */ created_by: null | string; + + /** + * The distribution author. + */ vendor: string | null; + + /** + * Whether this is a public image that is deprecated. + */ deprecated: boolean; + + /** + * A timestamp of when this image will expire if it was automatically captured. + */ expiry: null | string; + + /** + * The current status of this image. + */ status: ImageStatus; + + /** + * A list of the capabilities of this image. + */ capabilities: ImageCapabilities[]; + + /** + * A list of the regions in which this image is available. + */ + regions: ImageRegion[]; + + /** + * A list of tags added to this image. + */ tags: string[]; } diff --git a/packages/manager/.changeset/pr-10541-changed-1717446372854.md b/packages/manager/.changeset/pr-10541-changed-1717446372854.md new file mode 100644 index 00000000000..2403d5c6323 --- /dev/null +++ b/packages/manager/.changeset/pr-10541-changed-1717446372854.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Add `regions` and `total_size` fields to `imageFactory` ([#10541](https://github.com/linode/manager/pull/10541)) diff --git a/packages/manager/src/factories/images.ts b/packages/manager/src/factories/images.ts index ec80a043440..18a6246e599 100644 --- a/packages/manager/src/factories/images.ts +++ b/packages/manager/src/factories/images.ts @@ -12,9 +12,11 @@ export const imageFactory = Factory.Sync.makeFactory({ id: Factory.each((id) => `private/${id}`), is_public: false, label: Factory.each((i) => `image-${i}`), + regions: [], size: 1500, status: 'available', tags: [], + total_size: 1500, type: 'manual', updated: new Date().toISOString(), vendor: null, diff --git a/packages/validation/.changeset/pr-10541-added-1717446497849.md b/packages/validation/.changeset/pr-10541-added-1717446497849.md new file mode 100644 index 00000000000..74db8363504 --- /dev/null +++ b/packages/validation/.changeset/pr-10541-added-1717446497849.md @@ -0,0 +1,5 @@ +--- +"@linode/validation": Added +--- + +`updateImageRegionsSchema` ([#10541](https://github.com/linode/manager/pull/10541)) diff --git a/packages/validation/src/images.schema.ts b/packages/validation/src/images.schema.ts index 4ff45ebef4c..c8b3b6f9a45 100644 --- a/packages/validation/src/images.schema.ts +++ b/packages/validation/src/images.schema.ts @@ -12,7 +12,7 @@ export const baseImageSchema = object({ label: labelSchema.notRequired(), description: string().notRequired().min(1).max(65000), cloud_init: boolean().notRequired(), - tags: array(string()).notRequired(), + tags: array(string().min(3).max(50)).max(500).notRequired(), }); export const createImageSchema = baseImageSchema.shape({ @@ -33,3 +33,9 @@ export const updateImageSchema = object({ .max(65000, 'Length must be 65000 characters or less.'), tags: array(string()).notRequired(), }); + +export const updateImageRegionsSchema = object({ + regions: array(string()) + .required('Regions are required.') + .min(1, 'Must specify at least one region.'), +}); From bbbca9be5ca014862a82f007945fca8e40ae62c4 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Tue, 4 Jun 2024 15:15:36 -0400 Subject: [PATCH 051/163] refactor: [M3-8071] - Rename Edge Regions to Distributed Regions (#10452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Effort to bring clarity to new regions and plan offerings ## Changes 🔄 - Important: For our `types` and places we're checking for `site_type`, we're allowing both `edge` and `distributed` until the DB/API changes are ready. Then we'll do some cleanup afterwards. - Everywhere we used the term `edge` will now be called `distributed`. - In places where we only used the name/text `edge`, I've included `region` for clarity. - Example: `type SupportedEdgeTypes` => `type SupportedDistributedRegionTypes` ## Target release date 🗓️ 6/10 ## How to test 🧪 ### Prerequisites - Pull down branch and ensure `Gecko` feature flag is active in dev tools ### Reproduction steps - Try to create a Linode in an edge/distributed region - Observe `edge` should no longer be shown - Using MSW, try to migrate an edge/distributed linode to a non-distributed region - Observe warnings should be updated --------- Co-authored-by: Jaalah Ramos Co-authored-by: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Co-authored-by: Hana Xu <115299789+hana-linode@users.noreply.github.com> Co-authored-by: Hana Xu --- packages/api-v4/src/regions/types.ts | 2 +- .../core/images/machine-image-upload.spec.ts | 3 +- packages/manager/src/__data__/regionsData.ts | 12 ++-- ...edge-region.svg => distributed-region.svg} | 2 +- .../components/RegionSelect/RegionOption.tsx | 20 +++--- .../RegionSelect/RegionSelect.styles.ts | 40 ++++++------ .../RegionSelect/RegionSelect.test.tsx | 16 +++-- .../components/RegionSelect/RegionSelect.tsx | 42 +++++++----- .../RegionSelect/RegionSelect.types.ts | 4 +- .../RegionSelect/RegionSelect.utils.test.tsx | 49 +++++++------- .../RegionSelect/RegionSelect.utils.tsx | 31 ++++----- .../SelectRegionPanel/SelectRegionPanel.tsx | 18 ++--- .../src/features/Events/factories/backup.tsx | 2 +- .../src/features/Events/factories/lke.tsx | 3 +- .../features/Events/factories/reserved.tsx | 3 +- .../Images/ImagesCreate/CreateImageTab.tsx | 6 +- .../LinodeCreatev2/Addons/Addons.test.tsx | 6 +- .../Linodes/LinodeCreatev2/Addons/Addons.tsx | 8 ++- .../LinodeCreatev2/Addons/Backups.test.tsx | 4 +- .../Linodes/LinodeCreatev2/Addons/Backups.tsx | 8 ++- .../LinodeCreatev2/Addons/PrivateIP.test.tsx | 4 +- .../LinodeCreatev2/Addons/PrivateIP.tsx | 6 +- .../LinodeCreatev2/Addons/utilities.test.ts | 22 +++---- .../LinodeCreatev2/Addons/utilities.ts | 4 +- .../features/Linodes/LinodeEntityDetail.tsx | 9 ++- .../Linodes/LinodeEntityDetailBody.tsx | 6 +- .../LinodesCreate/AddonsPanel.test.tsx | 16 ++--- .../Linodes/LinodesCreate/AddonsPanel.tsx | 16 ++--- .../Linodes/LinodesCreate/LinodeCreate.tsx | 14 ++-- .../TabbedContent/FromBackupsContent.tsx | 5 +- .../TabbedContent/FromLinodeContent.tsx | 8 +-- .../LinodeBackup/BackupsPlaceholder.test.tsx | 8 +-- .../LinodeBackup/BackupsPlaceholder.tsx | 10 +-- .../LinodeBackup/LinodeBackups.tsx | 6 +- .../LinodeNetworking/AddIPDrawer.test.tsx | 8 +-- .../LinodeNetworking/AddIPDrawer.tsx | 18 +++-- .../LinodeNetworking/LinodeIPAddresses.tsx | 6 +- .../LinodeActionMenu.test.tsx | 8 +-- .../LinodeActionMenu/LinodeActionMenu.tsx | 18 +++-- .../Linodes/MigrateLinode/CautionNotice.tsx | 6 +- .../Linodes/MigrateLinode/ConfigureForm.tsx | 20 +++--- .../Linodes/MigrateLinode/MigrateLinode.tsx | 12 ++-- .../PlansPanel/DistributedRegionPlanTable.tsx | 65 +++++++++++++++++++ .../components/PlansPanel/EdgePlanTable.tsx | 63 ------------------ .../components/PlansPanel/PlansPanel.tsx | 64 +++++++----------- packages/manager/src/mocks/serverHandlers.ts | 14 ++-- 46 files changed, 366 insertions(+), 349 deletions(-) rename packages/manager/src/assets/icons/entityIcons/{edge-region.svg => distributed-region.svg} (94%) create mode 100644 packages/manager/src/features/components/PlansPanel/DistributedRegionPlanTable.tsx delete mode 100644 packages/manager/src/features/components/PlansPanel/EdgePlanTable.tsx diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index 30d6e03826c..30329f4e8f8 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -25,7 +25,7 @@ export interface DNSResolvers { export type RegionStatus = 'ok' | 'outage'; -export type RegionSite = 'core' | 'edge'; +export type RegionSite = 'core' | 'distributed' | 'edge'; export interface Region { id: string; diff --git a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts index f5d5e051b46..fbd28300d66 100644 --- a/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts +++ b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts @@ -134,7 +134,8 @@ const uploadImage = (label: string) => { cy.intercept('POST', apiMatcher('images/upload')).as('imageUpload'); - ui.button.findByAttribute('type', 'submit') + ui.button + .findByAttribute('type', 'submit') .should('be.enabled') .should('be.visible') .click(); diff --git a/packages/manager/src/__data__/regionsData.ts b/packages/manager/src/__data__/regionsData.ts index 37d3909ce5b..0a3ab6eaf2e 100644 --- a/packages/manager/src/__data__/regionsData.ts +++ b/packages/manager/src/__data__/regionsData.ts @@ -683,8 +683,8 @@ export const regions: Region[] = [ { capabilities: ['Linodes'], country: 'us', - id: 'us-edge-1', - label: 'Gecko Edge Test', + id: 'us-den-10', + label: 'Gecko Distributed Region Test', placement_group_limits: { maximum_linodes_per_pg: 10, maximum_pgs_per_customer: 5, @@ -695,14 +695,14 @@ export const regions: Region[] = [ ipv6: '2a01:7e01::5, 2a01:7e01::9, 2a01:7e01::7, 2a01:7e01::c, 2a01:7e01::2, 2a01:7e01::4, 2a01:7e01::3, 2a01:7e01::6, 2a01:7e01::b, 2a01:7e01::8', }, - site_type: 'edge', + site_type: 'distributed', status: 'ok', }, { capabilities: ['Linodes'], country: 'us', - id: 'us-edge-2', - label: 'Gecko Edge Test 2', + id: 'us-den-11', + label: 'Gecko Distributed Region Test 2', placement_group_limits: { maximum_linodes_per_pg: 10, maximum_pgs_per_customer: 5, @@ -713,7 +713,7 @@ export const regions: Region[] = [ ipv6: '2a01:7e01::5, 2a01:7e01::9, 2a01:7e01::7, 2a01:7e01::c, 2a01:7e01::2, 2a01:7e01::4, 2a01:7e01::3, 2a01:7e01::6, 2a01:7e01::b, 2a01:7e01::8', }, - site_type: 'edge', + site_type: 'distributed', status: 'ok', }, ]; diff --git a/packages/manager/src/assets/icons/entityIcons/edge-region.svg b/packages/manager/src/assets/icons/entityIcons/distributed-region.svg similarity index 94% rename from packages/manager/src/assets/icons/entityIcons/edge-region.svg rename to packages/manager/src/assets/icons/entityIcons/distributed-region.svg index 6a8889a6581..a9e44da4e6d 100644 --- a/packages/manager/src/assets/icons/entityIcons/edge-region.svg +++ b/packages/manager/src/assets/icons/entityIcons/distributed-region.svg @@ -1,6 +1,6 @@ -edge-region +distributed-region diff --git a/packages/manager/src/components/RegionSelect/RegionOption.tsx b/packages/manager/src/components/RegionSelect/RegionOption.tsx index 3e8918c70aa..33c4a1b36f9 100644 --- a/packages/manager/src/components/RegionSelect/RegionOption.tsx +++ b/packages/manager/src/components/RegionSelect/RegionOption.tsx @@ -1,7 +1,7 @@ import { visuallyHidden } from '@mui/utils'; import React from 'react'; -import EdgeRegion from 'src/assets/icons/entityIcons/edge-region.svg'; +import DistributedRegion from 'src/assets/icons/entityIcons/distributed-region.svg'; import { Box } from 'src/components/Box'; import { Flag } from 'src/components/Flag'; import { Tooltip } from 'src/components/Tooltip'; @@ -11,21 +11,21 @@ import { SelectedIcon, StyledFlagContainer, StyledListItem, - sxEdgeIcon, + sxDistributedRegionIcon, } from './RegionSelect.styles'; import { RegionSelectOption } from './RegionSelect.types'; import type { ListItemComponentsPropsOverrides } from '@mui/material/ListItem'; type Props = { - displayEdgeRegionIcon?: boolean; + displayDistributedRegionIcon?: boolean; option: RegionSelectOption; props: React.HTMLAttributes; selected?: boolean; }; export const RegionOption = ({ - displayEdgeRegionIcon, + displayDistributedRegionIcon, option, props, selected, @@ -75,9 +75,9 @@ export const RegionOption = ({ {label} - {displayEdgeRegionIcon && ( + {displayDistributedRegionIcon && ( -  (This region is an edge region.) +  (This region is a distributed region.) )} {isRegionDisabled && isRegionDisabledReason && ( @@ -85,12 +85,12 @@ export const RegionOption = ({ )} {selected && } - {displayEdgeRegionIcon && ( + {displayDistributedRegionIcon && ( } + icon={} status="other" - sxTooltipIcon={sxEdgeIcon} - text="This region is an edge region." + sxTooltipIcon={sxDistributedRegionIcon} + text="This region is a distributed region." /> )} diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts index f155d0a96e2..1d53b231832 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts @@ -30,7 +30,7 @@ export const StyledAutocompleteContainer = styled(Box, { }, })); -export const sxEdgeIcon = { +export const sxDistributedRegionIcon = { '& svg': { color: 'inherit !important', height: 21, @@ -43,28 +43,28 @@ export const sxEdgeIcon = { padding: 0, }; -export const StyledEdgeBox = styled(Box, { label: 'StyledEdgeBox' })( - ({ theme }) => ({ - '& svg': { - height: 21, - marginLeft: 8, - marginRight: 8, - width: 24, - }, - alignSelf: 'end', - color: 'inherit', - display: 'flex', +export const StyledDistributedRegionBox = styled(Box, { + label: 'StyledDistributedRegionBox', +})(({ theme }) => ({ + '& svg': { + height: 21, marginLeft: 8, - padding: '8px 0', - [theme.breakpoints.down('md')]: { - '& svg': { - marginLeft: 0, - }, - alignSelf: 'start', + marginRight: 8, + width: 24, + }, + alignSelf: 'end', + color: 'inherit', + display: 'flex', + marginLeft: 8, + padding: '8px 0', + [theme.breakpoints.down('md')]: { + '& svg': { marginLeft: 0, }, - }) -); + alignSelf: 'start', + marginLeft: 0, + }, +})); export const StyledFlagContainer = styled('div', { label: 'RegionSelectFlagContainer', diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx index 079579855fb..13fe9614d57 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx @@ -66,21 +66,25 @@ describe('RegionSelect', () => { expect(getByTestId('textfield-input')).toBeDisabled(); }); - it('should render a Select component with edge region text', () => { + it('should render a Select component with distributed region text', () => { const newProps = { ...props, - showEdgeIconHelperText: true, + showDistributedRegionIconHelperText: true, }; const { getByTestId } = renderWithTheme(); - expect(getByTestId('region-select-edge-text')).toBeInTheDocument(); + expect( + getByTestId('region-select-distributed-region-text') + ).toBeInTheDocument(); }); - it('should render a Select component with no edge region text', () => { + it('should render a Select component with no distributed region text', () => { const newProps = { ...props, - showEdgeIconHelperText: false, + showDistributedRegionIconHelperText: false, }; const { queryByTestId } = renderWithTheme(); - expect(queryByTestId('region-select-edge-text')).not.toBeInTheDocument(); + expect( + queryByTestId('region-select-distributed-region-text') + ).not.toBeInTheDocument(); }); }); diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index d1603412ef5..745857911a1 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -1,7 +1,7 @@ import { Typography } from '@mui/material'; import * as React from 'react'; -import EdgeRegion from 'src/assets/icons/entityIcons/edge-region.svg'; +import DistributedRegion from 'src/assets/icons/entityIcons/distributed-region.svg'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Flag } from 'src/components/Flag'; import { Link } from 'src/components/Link'; @@ -11,9 +11,9 @@ import { useAllAccountAvailabilitiesQuery } from 'src/queries/account/availabili import { RegionOption } from './RegionOption'; import { StyledAutocompleteContainer, - StyledEdgeBox, + StyledDistributedRegionBox, StyledFlagContainer, - sxEdgeIcon, + sxDistributedRegionIcon, } from './RegionSelect.styles'; import { getRegionOptions, getSelectedRegionById } from './RegionSelect.utils'; @@ -26,7 +26,7 @@ import type { * A specific select for regions. * * The RegionSelect automatically filters regions based on capability using its `currentCapability` prop. For example, if - * `currentCapability="VPCs"`, only regions that support VPCs will appear in the RegionSelect dropdown. Edge regions are filtered based on the `regionFilter` prop. + * `currentCapability="VPCs"`, only regions that support VPCs will appear in the RegionSelect dropdown. Distributed regions are filtered based on the `regionFilter` prop. * There is no need to pre-filter regions when passing them to the RegionSelect. See the description of `currentCapability` prop for more information. * * We do not display the selected check mark for single selects. @@ -45,7 +45,7 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { regions, required, selectedId, - showEdgeIconHelperText, + showDistributedRegionIconHelperText, tooltipText, width, } = props; @@ -115,8 +115,10 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { renderOption={(props, option) => { return ( { ...props.textFieldProps, InputProps: { endAdornment: regionFilter !== 'core' && - selectedRegion?.site_type === 'edge' && ( + (selectedRegion?.site_type === 'distributed' || + selectedRegion?.site_type === 'edge') && ( } + icon={} status="other" - sxTooltipIcon={sxEdgeIcon} - text="This region is an edge region." + sxTooltipIcon={sxDistributedRegionIcon} + text="This region is a distributed region." /> ), required, @@ -166,21 +169,24 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { placeholder="Select a Region" value={selectedRegion} /> - {showEdgeIconHelperText && ( // @TODO Gecko Beta: Add docs link - - + {showDistributedRegionIconHelperText && ( // @TODO Gecko Beta: Add docs link + + {' '} - Indicates an edge region.{' '} - + Indicates a distributed region.{' '} + Learn more . - + )} ); diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index e8a32a92190..e7a37e6c9dd 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -48,7 +48,7 @@ export interface RegionSelectProps regions: Region[]; required?: boolean; selectedId: null | string; - showEdgeIconHelperText?: boolean; + showDistributedRegionIconHelperText?: boolean; tooltipText?: string; width?: number; } @@ -104,4 +104,4 @@ export interface GetSelectedRegionsByIdsArgs { selectedRegionIds: string[]; } -export type SupportedEdgeTypes = 'Distributions' | 'StackScripts'; +export type SupportedDistributedRegionTypes = 'Distributions' | 'StackScripts'; diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx index 8bdf1472598..d7c05901120 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx @@ -38,21 +38,21 @@ const regions: Region[] = [ }), ]; -const regionsWithEdge = [ +const distributedRegions = [ ...regions, regionFactory.build({ capabilities: ['Linodes'], country: 'us', - id: 'us-edge-1', - label: 'Gecko Edge Test', - site_type: 'edge', + id: 'us-den-10', + label: 'Gecko Distributed Region Test', + site_type: 'distributed', }), regionFactory.build({ capabilities: ['Linodes'], country: 'us', - id: 'us-edge-2', - label: 'Gecko Edge Test 2', - site_type: 'edge', + id: 'us-den-11', + label: 'Gecko Distributed Region Test 2', + site_type: 'distributed', }), ]; @@ -89,24 +89,24 @@ const expectedRegions: RegionSelectOption[] = [ }, ]; -const expectedEdgeRegions = [ +const expectedDistributedRegions = [ { data: { country: 'us', region: 'North America' }, disabledProps: { disabled: false, }, - label: 'Gecko Edge Test (us-edge-1)', - site_type: 'edge', - value: 'us-edge-1', + label: 'Gecko Distributed Region Test (us-den-10)', + site_type: 'distributed', + value: 'us-den-10', }, { data: { country: 'us', region: 'North America' }, disabledProps: { disabled: false, }, - label: 'Gecko Edge Test 2 (us-edge-2)', - site_type: 'edge', - value: 'us-edge-2', + label: 'Gecko Distributed Region Test 2 (us-den-11)', + site_type: 'distributed', + value: 'us-den-11', }, ]; @@ -152,42 +152,39 @@ describe('getRegionOptions', () => { expect(result).toEqual(expectedRegions); }); - it('should filter out edge regions if regionFilter is core', () => { + it('should filter out distributed regions if regionFilter is core', () => { const result: RegionSelectOption[] = getRegionOptions({ accountAvailabilityData, currentCapability: 'Linodes', regionFilter: 'core', - regions: regionsWithEdge, + regions: distributedRegions, }); expect(result).toEqual(expectedRegions); }); - it('should filter out core regions if regionFilter is edge', () => { + it('should filter out core regions if regionFilter is "distributed"', () => { const result: RegionSelectOption[] = getRegionOptions({ accountAvailabilityData, currentCapability: 'Linodes', - regionFilter: 'edge', - regions: regionsWithEdge, + regionFilter: 'distributed', + regions: distributedRegions, }); - expect(result).toEqual(expectedEdgeRegions); + expect(result).toEqual(expectedDistributedRegions); }); it('should not filter out any regions if regionFilter is undefined', () => { - const expectedRegionsWithEdge = [ - ...expectedEdgeRegions, - ...expectedRegions, - ]; + const regions = [...expectedDistributedRegions, ...expectedRegions]; const result: RegionSelectOption[] = getRegionOptions({ accountAvailabilityData, currentCapability: 'Linodes', regionFilter: undefined, - regions: regionsWithEdge, + regions: distributedRegions, }); - expect(result).toEqual(expectedRegionsWithEdge); + expect(result).toEqual(regions); }); it('should have its option disabled if the region is unavailable', () => { diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx index 7c9d84b3b1a..41c782d9e57 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx @@ -11,7 +11,7 @@ import type { GetSelectedRegionById, GetSelectedRegionsByIdsArgs, RegionSelectOption, - SupportedEdgeTypes, + SupportedDistributedRegionTypes, } from './RegionSelect.types'; import type { AccountAvailability, Region } from '@linode/api-v4'; import type { LinodeCreateType } from 'src/features/Linodes/LinodesCreate/types'; @@ -201,36 +201,33 @@ export const getSelectedRegionsByIds = ({ }; /** - * Util to determine whether a create type has support for edge regions. + * Util to determine whether a create type has support for distributed regions. * - * @returns a boolean indicating whether or not the create type is edge supported. + * @returns a boolean indicating whether or not the create type supports distributed regions. */ -export const getIsLinodeCreateTypeEdgeSupported = ( - createType: LinodeCreateType -) => { - const supportedEdgeTypes: SupportedEdgeTypes[] = [ +export const isDistributedRegionSupported = (createType: LinodeCreateType) => { + const supportedDistributedRegionTypes: SupportedDistributedRegionTypes[] = [ 'Distributions', 'StackScripts', ]; return ( - supportedEdgeTypes.includes(createType as SupportedEdgeTypes) || - typeof createType === 'undefined' // /linodes/create route + supportedDistributedRegionTypes.includes( + createType as SupportedDistributedRegionTypes + ) || typeof createType === 'undefined' // /linodes/create route ); }; /** - * Util to determine whether a selected region is an edge region. + * Util to determine whether a selected region is a distributed region. * - * @returns a boolean indicating whether or not the selected region is an edge region. + * @returns a boolean indicating whether or not the selected region is a distributed region. */ -export const getIsEdgeRegion = ( +export const getIsDistributedRegion = ( regionsData: Region[], selectedRegion: string ) => { - return ( - regionsData.find( - (region) => - region.id === selectedRegion || region.label === selectedRegion - )?.site_type === 'edge' + const region = regionsData.find( + (region) => region.id === selectedRegion || region.label === selectedRegion ); + return region?.site_type === 'distributed' || region?.site_type === 'edge'; }; diff --git a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx index 2f6c9fe21b0..1fbe47fa423 100644 --- a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx +++ b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx @@ -6,7 +6,7 @@ import { useLocation } from 'react-router-dom'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { getIsLinodeCreateTypeEdgeSupported } from 'src/components/RegionSelect/RegionSelect.utils'; +import { isDistributedRegionSupported } from 'src/components/RegionSelect/RegionSelect.utils'; import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; import { Typography } from 'src/components/Typography'; import { CROSS_DATA_CENTER_CLONE_WARNING } from 'src/features/Linodes/LinodesCreate/constants'; @@ -82,17 +82,17 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { type, }); - const hideEdgeRegions = + const hideDistributedRegions = !flags.gecko2?.enabled || flags.gecko2?.ga || - !getIsLinodeCreateTypeEdgeSupported(params.type as LinodeCreateType); + !isDistributedRegionSupported(params.type as LinodeCreateType); - const showEdgeIconHelperText = Boolean( - !hideEdgeRegions && + const showDistributedRegionIconHelperText = Boolean( + !hideDistributedRegions && currentCapability && regions?.find( (region) => - region.site_type === 'edge' && + (region.site_type === 'distributed' || region.site_type === 'edge') && region.capabilities.includes(currentCapability) ) ); @@ -152,10 +152,12 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { errorText={error} handleSelection={handleSelection} helperText={helperText} - regionFilter={hideEdgeRegions ? 'core' : undefined} + regionFilter={hideDistributedRegions ? 'core' : undefined} regions={regions ?? []} selectedId={selectedId || null} - showEdgeIconHelperText={showEdgeIconHelperText} + showDistributedRegionIconHelperText={ + showDistributedRegionIconHelperText + } {...RegionSelectProps} /> {showClonePriceWarning && ( diff --git a/packages/manager/src/features/Events/factories/backup.tsx b/packages/manager/src/features/Events/factories/backup.tsx index 50f17ec8d53..31344a45197 100644 --- a/packages/manager/src/features/Events/factories/backup.tsx +++ b/packages/manager/src/features/Events/factories/backup.tsx @@ -22,7 +22,7 @@ export const backup: PartialEventMap<'backups'> = { backups_restore: { failed: (e) => ( <> - Backup could not be restored for + Backup could not be restored for {e.entity!.label}.{' '} Learn more about limits and considerations. diff --git a/packages/manager/src/features/Events/factories/lke.tsx b/packages/manager/src/features/Events/factories/lke.tsx index 209a1a8b5aa..7b10cf71263 100644 --- a/packages/manager/src/features/Events/factories/lke.tsx +++ b/packages/manager/src/features/Events/factories/lke.tsx @@ -82,7 +82,8 @@ export const lke: PartialEventMap<'lke'> = { // The entity is the node pool, but entity.label contains the cluster's label. notification: (e) => ( <> - Kubernetes Cluster node could not be created + Kubernetes Cluster node could not be{' '} + created {e.entity?.label ? ' on ' : ''} . diff --git a/packages/manager/src/features/Events/factories/reserved.tsx b/packages/manager/src/features/Events/factories/reserved.tsx index 0160daff657..45aaa20d2e8 100644 --- a/packages/manager/src/features/Events/factories/reserved.tsx +++ b/packages/manager/src/features/Events/factories/reserved.tsx @@ -14,8 +14,7 @@ export const reserved: PartialEventMap<'reserved'> = { reserved_ip_create: { notification: () => ( <> - A reserved IP address has been created on your - account. + A reserved IP address has been created on your account. ), }, diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index d83f4af3fc1..9bd56e55c8b 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -15,7 +15,7 @@ import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; -import { getIsEdgeRegion } from 'src/components/RegionSelect/RegionSelect.utils'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { Stack } from 'src/components/Stack'; import { SupportLink } from 'src/components/SupportLink'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; @@ -113,7 +113,7 @@ export const CreateImageTab = () => { Boolean(selectedLinodeId) && isDiskEncryptionFeatureEnabled ); - const linodeIsInEdgeRegion = getIsEdgeRegion( + const linodeIsInDistributedRegion = getIsDistributedRegion( regionsData ?? [], linode?.region ?? '' ); @@ -183,7 +183,7 @@ export const CreateImageTab = () => { value={selectedLinodeId} /> {isDiskEncryptionFeatureEnabled && - !linodeIsInEdgeRegion && + !linodeIsInDistributedRegion && selectedLinodeId !== null && ( { expect(heading.tagName).toBe('H2'); }); - it('renders a warning if an edge region is selected', async () => { - const region = regionFactory.build({ site_type: 'edge' }); + it('renders a warning if a distributed region is selected', async () => { + const region = regionFactory.build({ site_type: 'distributed' }); server.use( http.get('*/v4/regions', () => { @@ -34,7 +34,7 @@ describe('Linode Create v2 Addons', () => { }); await findByText( - 'Backups and Private IP are currently not available for Edge regions.' + 'Backups and Private IP are currently not available for distributed regions.' ); }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Addons.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Addons.tsx index f7c33cfb1cf..de19fca6b22 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Addons.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Addons.tsx @@ -23,15 +23,17 @@ export const Addons = () => { [regions, regionId] ); - const isEdgeRegionSelected = selectedRegion?.site_type === 'edge'; + const isDistributedRegionSelected = + selectedRegion?.site_type === 'distributed' || + selectedRegion?.site_type === 'edge'; return ( Add-ons - {isEdgeRegionSelected && ( + {isDistributedRegionSelected && ( )} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.test.tsx index 37324a0517d..c22078b3730 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.test.tsx @@ -64,8 +64,8 @@ describe('Linode Create V2 Backups Addon', () => { expect(checkbox).toBeChecked(); }); - it('should be disabled if an edge region is selected', async () => { - const region = regionFactory.build({ site_type: 'edge' }); + it('should be disabled if a distributed region is selected', async () => { + const region = regionFactory.build({ site_type: 'distributed' }); server.use( http.get('*/v4/regions', () => { diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.tsx index c8100f40e85..2f2a590e576 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/Backups.tsx @@ -51,18 +51,20 @@ export const Backups = () => { const isAccountBackupsEnabled = accountSettings?.backups_enabled ?? false; - const isEdgeRegionSelected = selectedRegion?.site_type === 'edge'; + const isDistributedRegionSelected = + selectedRegion?.site_type === 'distributed' || + selectedRegion?.site_type === 'edge'; const checked = getBackupsEnabledValue({ accountBackupsEnabled: isAccountBackupsEnabled, - isEdgeRegion: isEdgeRegionSelected, + isDistributedRegion: isDistributedRegionSelected, value: field.value, }); return ( { expect(checkbox).toBeChecked(); }); - it('should be disabled if an edge region is selected', async () => { - const region = regionFactory.build({ site_type: 'edge' }); + it('should be disabled if a distributed region is selected', async () => { + const region = regionFactory.build({ site_type: 'distributed' }); server.use( http.get('*/v4/regions', () => { diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/PrivateIP.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/PrivateIP.tsx index 8f6bace7b1a..9c640e5fb2f 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/PrivateIP.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/PrivateIP.tsx @@ -28,7 +28,9 @@ export const PrivateIP = () => { [regions, regionId] ); - const isEdgeRegionSelected = selectedRegion?.site_type === 'edge'; + const isDistributedRegionSelected = + selectedRegion?.site_type === 'distributed' || + selectedRegion?.site_type === 'edge'; return ( { } checked={field.value ?? false} control={} - disabled={isEdgeRegionSelected || isLinodeCreateRestricted} + disabled={isDistributedRegionSelected || isLinodeCreateRestricted} onChange={field.onChange} /> ); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/utilities.test.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/utilities.test.ts index 6b7bfa2bee4..3d7d4a6bd9e 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/utilities.test.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/utilities.test.ts @@ -5,21 +5,21 @@ describe('getBackupsEnabledValue', () => { expect( getBackupsEnabledValue({ accountBackupsEnabled: true, - isEdgeRegion: false, + isDistributedRegion: false, value: false, }) ).toBe(true); expect( getBackupsEnabledValue({ accountBackupsEnabled: true, - isEdgeRegion: false, + isDistributedRegion: false, value: true, }) ).toBe(true); expect( getBackupsEnabledValue({ accountBackupsEnabled: true, - isEdgeRegion: false, + isDistributedRegion: false, value: undefined, }) ).toBe(true); @@ -29,14 +29,14 @@ describe('getBackupsEnabledValue', () => { expect( getBackupsEnabledValue({ accountBackupsEnabled: false, - isEdgeRegion: false, + isDistributedRegion: false, value: true, }) ).toBe(true); expect( getBackupsEnabledValue({ accountBackupsEnabled: false, - isEdgeRegion: false, + isDistributedRegion: false, value: false, }) ).toBe(false); @@ -46,7 +46,7 @@ describe('getBackupsEnabledValue', () => { expect( getBackupsEnabledValue({ accountBackupsEnabled: false, - isEdgeRegion: false, + isDistributedRegion: false, value: undefined, }) ).toBe(false); @@ -56,31 +56,31 @@ describe('getBackupsEnabledValue', () => { expect( getBackupsEnabledValue({ accountBackupsEnabled: undefined, - isEdgeRegion: false, + isDistributedRegion: false, value: false, }) ).toBe(false); expect( getBackupsEnabledValue({ accountBackupsEnabled: undefined, - isEdgeRegion: false, + isDistributedRegion: false, value: true, }) ).toBe(true); }); - it('should always return false if an edge region is selected becuase edge regions do not support backups', () => { + it('should always return false if a distributed region is selected because distributed regions do not support backups', () => { expect( getBackupsEnabledValue({ accountBackupsEnabled: undefined, - isEdgeRegion: true, + isDistributedRegion: true, value: true, }) ).toBe(false); expect( getBackupsEnabledValue({ accountBackupsEnabled: undefined, - isEdgeRegion: true, + isDistributedRegion: true, value: false, }) ).toBe(false); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/utilities.ts index 5eee1d33e93..fccad7aa0c7 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Addons/utilities.ts @@ -1,11 +1,11 @@ interface BackupsEnabledOptions { accountBackupsEnabled: boolean | undefined; - isEdgeRegion: boolean; + isDistributedRegion: boolean; value: boolean | undefined; } export const getBackupsEnabledValue = (options: BackupsEnabledOptions) => { - if (options.isEdgeRegion) { + if (options.isDistributedRegion) { return false; } diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx index b61b2862a35..ffcaf1e4a7b 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { EntityDetail } from 'src/components/EntityDetail/EntityDetail'; import { Notice } from 'src/components/Notice/Notice'; -import { getIsEdgeRegion } from 'src/components/RegionSelect/RegionSelect.utils'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { notificationContext as _notificationContext } from 'src/features/NotificationCenter/NotificationContext'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; @@ -81,7 +81,10 @@ export const LinodeEntityDetail = (props: Props) => { const linodeRegionDisplay = regions?.find((r) => r.id === linode.region)?.label ?? linode.region; - const linodeIsInEdgeRegion = getIsEdgeRegion(regions ?? [], linode.region); + const linodeIsInDistributedRegion = getIsDistributedRegion( + regions ?? [], + linode.region + ); let progress; let transitionText; @@ -114,7 +117,7 @@ export const LinodeEntityDetail = (props: Props) => { ipv6={trimmedIPv6} isVPCOnlyLinode={isVPCOnlyLinode} linodeId={linode.id} - linodeIsInEdgeRegion={linodeIsInEdgeRegion} + linodeIsInDistributedRegion={linodeIsInDistributedRegion} linodeLabel={linode.label} numCPUs={linode.specs.vcpus} numVolumes={numberOfVolumes} diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx index 93e05b6b6ba..6dc9f6d9cd2 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx @@ -47,7 +47,7 @@ export interface BodyProps { ipv6: Linode['ipv6']; isVPCOnlyLinode: boolean; linodeId: number; - linodeIsInEdgeRegion: boolean; + linodeIsInDistributedRegion: boolean; linodeLabel: string; numCPUs: number; numVolumes: number; @@ -64,7 +64,7 @@ export const LinodeEntityDetailBody = React.memo((props: BodyProps) => { ipv6, isVPCOnlyLinode, linodeId, - linodeIsInEdgeRegion, + linodeIsInDistributedRegion, linodeLabel, numCPUs, numVolumes, @@ -151,7 +151,7 @@ export const LinodeEntityDetailBody = React.memo((props: BodyProps) => { { heading: 'SSH Access', text: sshLink(ipv4[0]) }, { heading: 'LISH Console via SSH', - text: linodeIsInEdgeRegion + text: linodeIsInDistributedRegion ? 'N/A' : lishLink(username, region, linodeLabel), }, diff --git a/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.test.tsx b/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.test.tsx index cf1e73cbeed..d0ab398c4da 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.test.tsx @@ -269,25 +269,25 @@ describe('AddonsPanel', () => { expect(getByText(/\$3.57/)).toBeInTheDocument(); }); - it('should render a warning notice if isEdgeRegionSelected is true and disable backups and private ip checkbox', () => { - const propsWithEdgeRegionSelected = { + it('should render a warning notice if isDistributedRegionSelected is true and disable backups and private ip checkbox', () => { + const propsWithDistributedRegionSelected = { ...props, - isEdgeRegionSelected: true, + isDistributedRegionSelected: true, }; const { getByTestId } = renderWithTheme( - + ); expect(getByTestId('notice-warning')).toBeInTheDocument(); expect(getByTestId('private_ip')).toHaveAttribute('aria-disabled', 'true'); expect(getByTestId('backups')).toHaveAttribute('aria-disabled', 'true'); }); - it('should not render a warning notice if isEdgeRegionSelected is false', () => { - const propsWithEdgeRegionNotSelected = { + it('should not render a warning notice if isDistributedRegionSelected is false', () => { + const propsWithDistributedRegionNotSelected = { ...props, - isEdgeRegionSelected: false, + isDistributedRegionSelected: false, }; const { queryByTestId } = renderWithTheme( - + ); expect(queryByTestId('notice-warning')).not.toBeInTheDocument(); }); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.tsx index d4811b676e8..65b1fd8ef7f 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/AddonsPanel.tsx @@ -39,7 +39,7 @@ export interface AddonsPanelProps { handleVLANChange: (updatedInterface: Interface) => void; ipamAddress: string; ipamError?: string; - isEdgeRegionSelected?: boolean; + isDistributedRegionSelected?: boolean; isPrivateIPChecked: boolean; labelError?: string; linodesData?: Linode[]; @@ -63,7 +63,7 @@ export const AddonsPanel = React.memo((props: AddonsPanelProps) => { handleVLANChange, ipamAddress, ipamError, - isEdgeRegionSelected, + isDistributedRegionSelected, isPrivateIPChecked, labelError, linodesData, @@ -154,7 +154,7 @@ export const AddonsPanel = React.memo((props: AddonsPanelProps) => { }, [selectedLinodeID]); const isBackupsBoxChecked = - (accountBackups && !isEdgeRegionSelected) || props.backups; + (accountBackups && !isDistributedRegionSelected) || props.backups; return ( <> @@ -184,9 +184,9 @@ export const AddonsPanel = React.memo((props: AddonsPanelProps) => { )} - {isEdgeRegionSelected && ( + {isDistributedRegionSelected && ( )} @@ -206,7 +206,7 @@ export const AddonsPanel = React.memo((props: AddonsPanelProps) => { accountBackups || disabled || isBareMetal || - isEdgeRegionSelected + isDistributedRegionSelected } checked={isBackupsBoxChecked} data-testid="backups" @@ -233,7 +233,7 @@ export const AddonsPanel = React.memo((props: AddonsPanelProps) => { /> )} - {accountBackups && !isEdgeRegionSelected ? ( + {accountBackups && !isDistributedRegionSelected ? ( You have enabled automatic backups for your account. This Linode will automatically have backups enabled. To change this setting,{' '} @@ -254,7 +254,7 @@ export const AddonsPanel = React.memo((props: AddonsPanelProps) => { checked={isPrivateIPChecked} data-qa-check-private-ip data-testid="private_ip" - disabled={disabled || isEdgeRegionSelected} + disabled={disabled || isDistributedRegionSelected} onChange={togglePrivateIP} /> } diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index 92e9fb7b831..62ee744b9b3 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -24,7 +24,7 @@ import { DocsLink } from 'src/components/DocsLink/DocsLink'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; -import { getIsEdgeRegion } from 'src/components/RegionSelect/RegionSelect.utils'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { SelectRegionPanel } from 'src/components/SelectRegionPanel/SelectRegionPanel'; import { Stack } from 'src/components/Stack'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; @@ -368,7 +368,7 @@ export class LinodeCreate extends React.PureComponent< }); } - const linodeIsInEdgeRegion = getIsEdgeRegion( + const linodeIsInDistributedRegion = getIsDistributedRegion( regionsData, selectedRegionID ?? '' ); @@ -402,7 +402,7 @@ export class LinodeCreate extends React.PureComponent< } // @TODO Gecko: Remove $0 hardcoding once plan data is returned from API - if (linodeIsInEdgeRegion) { + if (linodeIsInDistributedRegion) { displaySections.push({ ...typeDisplayInfoCopy, details: '$0/month', @@ -429,7 +429,7 @@ export class LinodeCreate extends React.PureComponent< hasBackups && typeDisplayInfo && backupsMonthlyPrice && - !linodeIsInEdgeRegion + !linodeIsInDistributedRegion ) { displaySections.push( renderBackupsDisplaySection(accountBackupsEnabled, backupsMonthlyPrice) @@ -508,9 +508,9 @@ export class LinodeCreate extends React.PureComponent< ) && (imageIsCloudInitCompatible || linodeIsCloudInitCompatible); - const isEdgeRegionSelected = Boolean( + const isDistributedRegionSelected = Boolean( flags.gecko2?.enabled && - getIsEdgeRegion(regionsData, this.props.selectedRegionID ?? '') + getIsDistributedRegion(regionsData, this.props.selectedRegionID ?? '') ); return ( @@ -810,7 +810,7 @@ export class LinodeCreate extends React.PureComponent< handleVLANChange={this.props.handleVLANChange} ipamAddress={this.props.ipamAddress || ''} ipamError={hasErrorFor['interfaces[1].ipam_address']} - isEdgeRegionSelected={isEdgeRegionSelected} + isDistributedRegionSelected={isDistributedRegionSelected} isPrivateIPChecked={this.props.privateIPEnabled} labelError={hasErrorFor['interfaces[1].label']} linodesData={this.props.linodesData} diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx index d7e8d1b96c0..f228398dc1b 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromBackupsContent.tsx @@ -8,7 +8,7 @@ import * as React from 'react'; import VolumeIcon from 'src/assets/icons/entityIcons/volume.svg'; import { Paper } from 'src/components/Paper'; import { Placeholder } from 'src/components/Placeholder/Placeholder'; -import { getIsEdgeRegion } from 'src/components/RegionSelect/RegionSelect.utils'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { reportException } from 'src/exceptionReporting'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; @@ -82,7 +82,8 @@ export class FromBackupsContent extends React.Component { const filterLinodesWithBackups = (linodes: Linode[]) => linodes.filter( (linode) => - linode.backups.enabled && !getIsEdgeRegion(regionsData, linode.region) // Hide linodes that are in an edge region + linode.backups.enabled && + !getIsDistributedRegion(regionsData, linode.region) // Hide linodes that are in a distributed region ); return ( diff --git a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx index 8ced5a5a880..d756d80d76d 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/TabbedContent/FromLinodeContent.tsx @@ -5,7 +5,7 @@ import { useHistory } from 'react-router-dom'; import VolumeIcon from 'src/assets/icons/entityIcons/volume.svg'; import { Paper } from 'src/components/Paper'; import { Placeholder } from 'src/components/Placeholder/Placeholder'; -import { getIsEdgeRegion } from 'src/components/RegionSelect/RegionSelect.utils'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { buildQueryStringForLinodeClone } from 'src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenuUtils'; import { useFlags } from 'src/hooks/useFlags'; import { extendType } from 'src/utilities/extendType'; @@ -75,13 +75,13 @@ export const FromLinodeContent = (props: CombinedProps) => { } }; - const filterEdgeLinodes = (linodes: Linode[]) => + const filterDistributedRegionsLinodes = (linodes: Linode[]) => linodes.filter( - (linode) => !getIsEdgeRegion(regionsData, linode.region) // Hide linodes that are in an edge region + (linode) => !getIsDistributedRegion(regionsData, linode.region) // Hide linodes that are in a distributed region ); const filteredLinodes = flags.gecko2?.enabled - ? filterEdgeLinodes(linodesData) + ? filterDistributedRegionsLinodes(linodesData) : linodesData; return ( diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.test.tsx index 477f2d17c19..0e6252d7165 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.test.tsx @@ -5,12 +5,12 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { BackupsPlaceholder } from './BackupsPlaceholder'; describe('BackupsPlaceholder', () => { - it('should disable the enable backups button if linodeIsInEdgeRegion is true', () => { + it('should disable the enable backups button if linodeIsInDistributedRegion is true', () => { const { getByTestId } = renderWithTheme( ); expect(getByTestId('placeholder-button')).toHaveAttribute( @@ -18,12 +18,12 @@ describe('BackupsPlaceholder', () => { 'true' ); }); - it('should not disable the enable backups button if linodeIsInEdgeRegion is false', () => { + it('should not disable the enable backups button if linodeIsInDistributedRegion is false', () => { const { getByTestId } = renderWithTheme( ); expect(getByTestId('placeholder-button')).toHaveAttribute( diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.tsx index c897376e0db..f75c567dfda 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/BackupsPlaceholder.tsx @@ -13,7 +13,7 @@ interface Props { backupsMonthlyPrice?: PriceObject['monthly']; disabled: boolean; linodeId: number; - linodeIsInEdgeRegion?: boolean; + linodeIsInDistributedRegion?: boolean; } export const BackupsPlaceholder = React.memo((props: Props) => { @@ -21,7 +21,7 @@ export const BackupsPlaceholder = React.memo((props: Props) => { backupsMonthlyPrice, disabled, linodeId, - linodeIsInEdgeRegion, + linodeIsInDistributedRegion, } = props; const [dialogOpen, setDialogOpen] = React.useState(false); @@ -50,10 +50,10 @@ export const BackupsPlaceholder = React.memo((props: Props) => { buttonProps={[ { children: 'Enable Backups', - disabled: disabled || linodeIsInEdgeRegion, + disabled: disabled || linodeIsInDistributedRegion, onClick: () => setDialogOpen(true), - tooltipText: linodeIsInEdgeRegion - ? 'Backups are currently not available for Edge regions.' + tooltipText: linodeIsInDistributedRegion + ? 'Backups are currently not available for distributed regions.' : undefined, }, ]} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx index d43152f6cf6..5e445f5efdc 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx @@ -8,7 +8,7 @@ import { Button } from 'src/components/Button/Button'; import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Paper } from 'src/components/Paper'; -import { getIsEdgeRegion } from 'src/components/RegionSelect/RegionSelect.utils'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; @@ -64,7 +64,7 @@ export const LinodeBackups = () => { const [selectedBackup, setSelectedBackup] = React.useState(); - const linodeIsInEdgeRegion = getIsEdgeRegion( + const linodeIsInDistributedRegion = getIsDistributedRegion( regions ?? [], linode?.region ?? '' ); @@ -94,7 +94,7 @@ export const LinodeBackups = () => { backupsMonthlyPrice={backupsMonthlyPrice} disabled={doesNotHavePermission} linodeId={id} - linodeIsInEdgeRegion={linodeIsInEdgeRegion} + linodeIsInDistributedRegion={linodeIsInDistributedRegion} /> ); } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.test.tsx index c5ae8502b86..87715c2920e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.test.tsx @@ -5,11 +5,11 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { AddIPDrawer } from './AddIPDrawer'; describe('AddIPDrawer', () => { - it('should display a warning notice if linodeIsInEdgeRegion is true', () => { + it('should display a warning notice if linodeIsInDistributedRegion is true', () => { const { getByTestId } = renderWithTheme( null} open={true} readOnly={false} @@ -17,11 +17,11 @@ describe('AddIPDrawer', () => { ); expect(getByTestId('notice-warning')).toBeInTheDocument(); }); - it('should not display a warning notice if linodeIsInEdgeRegion is false', () => { + it('should not display a warning notice if linodeIsInDistributedRegion is false', () => { const { queryByTestId } = renderWithTheme( null} open={true} readOnly={false} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx index 0ea5b71cc17..6d8688f9b04 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx @@ -76,14 +76,20 @@ const tooltipCopy: Record = { interface Props { linodeId: number; - linodeIsInEdgeRegion?: boolean; + linodeIsInDistributedRegion?: boolean; onClose: () => void; open: boolean; readOnly: boolean; } export const AddIPDrawer = (props: Props) => { - const { linodeId, linodeIsInEdgeRegion, onClose, open, readOnly } = props; + const { + linodeId, + linodeIsInDistributedRegion, + onClose, + open, + readOnly, + } = props; const { error: ipv4Error, @@ -179,10 +185,10 @@ export const AddIPDrawer = (props: Props) => { onChange={handleIPv4Change} value={selectedIPv4} > - {linodeIsInEdgeRegion && ( + {linodeIsInDistributedRegion && ( )} @@ -192,7 +198,9 @@ export const AddIPDrawer = (props: Props) => { } data-qa-radio={option.label} - disabled={option.value === 'v4Private' && linodeIsInEdgeRegion} + disabled={ + option.value === 'v4Private' && linodeIsInDistributedRegion + } key={idx} label={option.label} value={option.value} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx index 2e6acad4c81..21139e99edd 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx @@ -10,7 +10,7 @@ import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Hidden } from 'src/components/Hidden'; import OrderBy from 'src/components/OrderBy'; import { Paper } from 'src/components/Paper'; -import { getIsEdgeRegion } from 'src/components/RegionSelect/RegionSelect.utils'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; @@ -54,7 +54,7 @@ export const LinodeIPAddresses = (props: LinodeIPAddressesProps) => { const { data: linode } = useLinodeQuery(linodeID); const { data: regions } = useRegionsQuery(); - const linodeIsInEdgeRegion = getIsEdgeRegion( + const linodeIsInDistributedRegion = getIsDistributedRegion( regions ?? [], linode?.region ?? '' ); @@ -257,7 +257,7 @@ export const LinodeIPAddresses = (props: LinodeIPAddressesProps) => { /> setIsAddDrawerOpen(false)} open={isAddDrawerOpen} readOnly={isLinodesGrantReadOnly} diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx index c84d08f98ba..9b2fa108385 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.test.tsx @@ -126,14 +126,14 @@ describe('LinodeActionMenu', () => { ); }); - it('should disable the clone action if the Linode is in an edge region', async () => { - const propsWithEdgeRegion = { + it('should disable the clone action if the Linode is in a distributed region', async () => { + const propsWithDistributedRegion = { ...props, - linodeRegion: 'us-edge-1', + linodeRegion: 'us-den-10', }; const { getByLabelText, getByTestId } = renderWithTheme( - + ); await userEvent.click(getByLabelText(/^Action menu for/)); diff --git a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx index 61549293a1c..836555c2435 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/LinodeActionMenu/LinodeActionMenu.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { useHistory } from 'react-router-dom'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; -import { getIsEdgeRegion } from 'src/components/RegionSelect/RegionSelect.utils'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { ActionType, getRestrictedResourceText, @@ -81,10 +81,13 @@ export const LinodeActionMenu = (props: LinodeActionMenuProps) => { props.onOpenPowerDialog(action); }; - const linodeIsInEdgeRegion = getIsEdgeRegion(regions, linodeRegion); + const linodeIsInDistributedRegion = getIsDistributedRegion( + regions, + linodeRegion + ); - const edgeRegionTooltipText = - 'Cloning is currently not supported for Edge instances.'; + const distributedRegionTooltipText = + 'Cloning is currently not supported for distributed region instances.'; const actionConfigs: ActionConfig[] = [ { @@ -124,7 +127,8 @@ export const LinodeActionMenu = (props: LinodeActionMenuProps) => { }, { condition: !isBareMetalInstance, - disabled: isLinodeReadOnly || hasHostMaintenance || linodeIsInEdgeRegion, + disabled: + isLinodeReadOnly || hasHostMaintenance || linodeIsInDistributedRegion, isReadOnly: isLinodeReadOnly, onClick: () => { sendLinodeActionMenuItemEvent('Clone'); @@ -141,8 +145,8 @@ export const LinodeActionMenu = (props: LinodeActionMenuProps) => { }, title: 'Clone', tooltipAction: 'clone', - tooltipText: linodeIsInEdgeRegion - ? edgeRegionTooltipText + tooltipText: linodeIsInDistributedRegion + ? distributedRegionTooltipText : maintenanceTooltipText, }, { diff --git a/packages/manager/src/features/Linodes/MigrateLinode/CautionNotice.tsx b/packages/manager/src/features/Linodes/MigrateLinode/CautionNotice.tsx index 6049904ce48..6f622014818 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/CautionNotice.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/CautionNotice.tsx @@ -10,7 +10,7 @@ import { API_MAX_PAGE_SIZE } from 'src/constants'; import { useLinodeVolumesQuery } from 'src/queries/volumes/volumes'; interface Props { - edgeRegionWarning?: string; + distributedRegionWarning?: string; error?: string; hasConfirmed: boolean; linodeId: number | undefined; @@ -21,7 +21,7 @@ interface Props { export const CautionNotice = React.memo((props: Props) => { const { - edgeRegionWarning, + distributedRegionWarning, error, hasConfirmed, linodeId, @@ -105,7 +105,7 @@ export const CautionNotice = React.memo((props: Props) => { to complete. {metadataWarning &&
  • {metadataWarning}
  • } - {edgeRegionWarning &&
  • {edgeRegionWarning}
  • } + {distributedRegionWarning &&
  • {distributedRegionWarning}
  • } {error && } { [backupEnabled, currentLinodeType] ); - const linodeIsInEdgeRegion = currentActualRegion?.site_type === 'edge'; + const linodeIsInDistributedRegion = + currentActualRegion?.site_type === 'distributed' || + currentActualRegion?.site_type === 'edge'; return ( @@ -157,12 +159,12 @@ export const ConfigureForm = React.memo((props: Props) => { {`${getRegionCountryGroup(currentActualRegion)}: ${ currentActualRegion?.label ?? currentRegion }`} - {linodeIsInEdgeRegion && ( + {linodeIsInDistributedRegion && ( } + icon={} status="other" - sxTooltipIcon={sxEdgeIcon} - text="This region is an edge region." + sxTooltipIcon={sxDistributedRegionIcon} + text="This region is a distributed region." /> )} @@ -175,7 +177,9 @@ export const ConfigureForm = React.memo((props: Props) => { { : undefined; }, [flags.metadata, linode, regionsData, selectedRegion]); - const linodeIsInEdgeRegion = getIsEdgeRegion( + const linodeIsInDistributedRegion = getIsDistributedRegion( regionsData ?? [], linode?.region ?? '' ); - const edgeRegionWarning = - flags.gecko2?.enabled && linodeIsInEdgeRegion - ? 'Edge regions may only be migrated to other edge regions.' + const distributedRegionWarning = + flags.gecko2?.enabled && linodeIsInDistributedRegion + ? 'Distributed regions may only be migrated to other distributed regions.' : undefined; if (!linode) { @@ -247,7 +247,7 @@ export const MigrateLinode = React.memo((props: Props) => { notifications={notifications} /> */} React.JSX.Element; + rootClass?: string; + sx?: SxProps; +} + +export const DistributedRegionPlanTable = React.memo( + (props: DistributedRegionPlanTableProps) => { + const { + copy, + docsLink, + error, + header, + innerClass, + renderTable, + rootClass, + sx, + } = props; + + return ( + +
    + + {header && ( + + {header} + + )} + {docsLink} + + {error && ( + + {error} + + )} + {copy && {copy}} + {renderTable()} +
    +
    + ); + } +); + +const StyledTypography = styled(Typography)(({ theme }) => ({ + fontSize: '0.875rem', + marginTop: theme.spacing(1), +})); diff --git a/packages/manager/src/features/components/PlansPanel/EdgePlanTable.tsx b/packages/manager/src/features/components/PlansPanel/EdgePlanTable.tsx deleted file mode 100644 index 932ac4d2f41..00000000000 --- a/packages/manager/src/features/components/PlansPanel/EdgePlanTable.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { styled } from '@mui/material/styles'; -import { SxProps } from '@mui/system'; -import React from 'react'; - -import { Box } from 'src/components/Box'; -import { Notice } from 'src/components/Notice/Notice'; -import { Paper } from 'src/components/Paper'; -import { Typography } from 'src/components/Typography'; - -interface EdgePlanTableProps { - copy?: string; - docsLink?: JSX.Element; - error?: JSX.Element | string; - header: string; - innerClass?: string; - renderTable: () => React.JSX.Element; - rootClass?: string; - sx?: SxProps; -} - -export const EdgePlanTable = React.memo((props: EdgePlanTableProps) => { - const { - copy, - docsLink, - error, - header, - innerClass, - renderTable, - rootClass, - sx, - } = props; - - return ( - -
    - - {header && ( - - {header} - - )} - {docsLink} - - {error && ( - - {error} - - )} - {copy && {copy}} - {renderTable()} -
    -
    - ); -}); - -const StyledTypography = styled(Typography)(({ theme }) => ({ - fontSize: '0.875rem', - marginTop: theme.spacing(1), -})); diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx index f29b3361440..b516c11e678 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx @@ -2,15 +2,15 @@ import * as React from 'react'; import { useLocation } from 'react-router-dom'; import { Notice } from 'src/components/Notice/Notice'; -import { getIsLinodeCreateTypeEdgeSupported } from 'src/components/RegionSelect/RegionSelect.utils'; -import { getIsEdgeRegion } from 'src/components/RegionSelect/RegionSelect.utils'; +import { isDistributedRegionSupported } from 'src/components/RegionSelect/RegionSelect.utils'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { TabbedPanel } from 'src/components/TabbedPanel/TabbedPanel'; import { useFlags } from 'src/hooks/useFlags'; import { useRegionAvailabilityQuery } from 'src/queries/regions/regions'; import { plansNoticesUtils } from 'src/utilities/planNotices'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; -import { EdgePlanTable } from './EdgePlanTable'; +import { DistributedRegionPlanTable } from './DistributedRegionPlanTable'; import { PlanContainer } from './PlanContainer'; import { PlanInformation } from './PlanInformation'; import { @@ -83,44 +83,26 @@ export const PlansPanel = (props: PlansPanelProps) => { flags.disableLargestGbPlans ? _types : types ); - const hideEdgeRegions = + const hideDistributedRegions = !flags.gecko2?.enabled || - !getIsLinodeCreateTypeEdgeSupported(params.type as LinodeCreateType); - - const showEdgePlanTable = - !hideEdgeRegions && - getIsEdgeRegion(regionsData ?? [], selectedRegionID ?? ''); - - const getDedicatedEdgePlanType = () => { - const edgePlans = types.filter((type) => type.class === 'edge'); - if (edgePlans.length) { - return edgePlans; - } - - // @TODO Remove fallback once edge plans are activated - // 256GB and 512GB plans will not be supported for Edge - const plansUpTo128GB = (_plans.dedicated ?? []).filter( - (planType) => - !['Dedicated 256 GB', 'Dedicated 512 GB'].includes( - planType.formattedLabel - ) + !isDistributedRegionSupported(params.type as LinodeCreateType); + + const showDistributedRegionPlanTable = + !hideDistributedRegions && + getIsDistributedRegion(regionsData ?? [], selectedRegionID ?? ''); + + const getDedicatedDistributedRegionPlanType = () => { + return types.filter( + (type) => + type.id.includes('dedicated-edge') || + type.id.includes('nanode-edge') || + type.class === 'edge' ); - - return plansUpTo128GB.map((plan) => { - delete plan.transfer; - return { - ...plan, - price: { - hourly: 0, - monthly: 0, - }, - }; - }); }; - const plans = showEdgePlanTable + const plans = showDistributedRegionPlanTable ? { - dedicated: getDedicatedEdgePlanType(), + dedicated: getDedicatedDistributedRegionPlanType(), } : _plans; @@ -156,7 +138,7 @@ export const PlansPanel = (props: PlansPanelProps) => { <> { planType={plan} regionsData={regionsData || []} /> - {showEdgePlanTable && ( + {showDistributedRegionPlanTable && ( )} @@ -200,9 +182,9 @@ export const PlansPanel = (props: PlansPanelProps) => { currentPlanHeading ); - if (showEdgePlanTable) { + if (showDistributedRegionPlanTable) { return ( - Date: Tue, 4 Jun 2024 17:59:53 -0400 Subject: [PATCH 052/163] =?UTF-8?q?upcoming:=20[M3-8019]=20=E2=80=93=20Add?= =?UTF-8?q?=20Encrypted/Not=20Encrypted=20status=20to=20Linode=20Detail=20?= =?UTF-8?q?summary=20header=20(#10537)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pr-10537-changed-1717182754934.md | 5 ++ packages/api-v4/src/linodes/types.ts | 1 + ...r-10537-upcoming-features-1717182633416.md | 5 ++ packages/manager/src/__data__/linodes.ts | 4 ++ packages/manager/src/assets/icons/lock.svg | 1 + packages/manager/src/assets/icons/unlock.svg | 1 + .../components/DiskEncryption/constants.tsx | 3 + packages/manager/src/factories/linodes.ts | 1 + .../NodePoolsDisplay/NodeTable.tsx | 6 +- .../Linodes/LinodeEntityDetail.test.tsx | 59 +++++++++++++++++++ .../features/Linodes/LinodeEntityDetail.tsx | 2 + .../Linodes/LinodeEntityDetailBody.tsx | 54 +++++++++++++++-- .../LinodesCreate/AddonsPanel.test.tsx | 3 + .../LinodeRow/LinodeRow.test.tsx | 1 + .../Linodes/LinodesLanding/ListView.tsx | 3 +- 15 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10537-changed-1717182754934.md create mode 100644 packages/manager/.changeset/pr-10537-upcoming-features-1717182633416.md diff --git a/packages/api-v4/.changeset/pr-10537-changed-1717182754934.md b/packages/api-v4/.changeset/pr-10537-changed-1717182754934.md new file mode 100644 index 00000000000..15cf633a71e --- /dev/null +++ b/packages/api-v4/.changeset/pr-10537-changed-1717182754934.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Add lke_cluster_id to Linode interface ([#10537](https://github.com/linode/manager/pull/10537)) diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 05f52e39d96..c3e1f6be296 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -27,6 +27,7 @@ export interface Linode { ipv4: string[]; ipv6: string | null; label: string; + lke_cluster_id: number | null; placement_group?: PlacementGroupPayload; // If not in a placement group, this will be excluded from the response. type: string | null; status: LinodeStatus; diff --git a/packages/manager/.changeset/pr-10537-upcoming-features-1717182633416.md b/packages/manager/.changeset/pr-10537-upcoming-features-1717182633416.md new file mode 100644 index 00000000000..0ffeb21d36f --- /dev/null +++ b/packages/manager/.changeset/pr-10537-upcoming-features-1717182633416.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Encrypted / Not Encrypted status to Linode Detail header ([#10537](https://github.com/linode/manager/pull/10537)) diff --git a/packages/manager/src/__data__/linodes.ts b/packages/manager/src/__data__/linodes.ts index 22ffdfee42d..ab5ad7a97ca 100644 --- a/packages/manager/src/__data__/linodes.ts +++ b/packages/manager/src/__data__/linodes.ts @@ -24,6 +24,7 @@ export const linode1: Linode = { ipv4: ['97.107.143.78', '98.107.143.78', '99.107.143.78'], ipv6: '2600:3c03::f03c:91ff:fe0a:109a/64', label: 'test', + lke_cluster_id: null, placement_group: { affinity_type: 'anti_affinity:local', id: 1, @@ -69,6 +70,7 @@ export const linode2: Linode = { ipv4: ['97.107.143.49'], ipv6: '2600:3c03::f03c:91ff:fe0a:0d7a/64', label: 'another-test', + lke_cluster_id: null, placement_group: { affinity_type: 'anti_affinity:local', id: 1, @@ -114,6 +116,7 @@ export const linode3: Linode = { ipv4: ['97.107.143.49'], ipv6: '2600:3c03::f03c:91ff:fe0a:0d7a/64', label: 'another-test', + lke_cluster_id: null, placement_group: { affinity_type: 'anti_affinity:local', id: 1, @@ -159,6 +162,7 @@ export const linode4: Linode = { ipv4: ['97.107.143.49'], ipv6: '2600:3c03::f03c:91ff:fe0a:0d7a/64', label: 'another-test-eu', + lke_cluster_id: null, placement_group: { affinity_type: 'anti_affinity:local', id: 1, diff --git a/packages/manager/src/assets/icons/lock.svg b/packages/manager/src/assets/icons/lock.svg index ca135909b4f..62caf228e55 100644 --- a/packages/manager/src/assets/icons/lock.svg +++ b/packages/manager/src/assets/icons/lock.svg @@ -1,3 +1,4 @@ +Encrypted diff --git a/packages/manager/src/assets/icons/unlock.svg b/packages/manager/src/assets/icons/unlock.svg index ce413046282..ae99e8d9a24 100644 --- a/packages/manager/src/assets/icons/unlock.svg +++ b/packages/manager/src/assets/icons/unlock.svg @@ -1,4 +1,5 @@ +Not Encrypted diff --git a/packages/manager/src/components/DiskEncryption/constants.tsx b/packages/manager/src/components/DiskEncryption/constants.tsx index a9799ec1c80..539b19d3a6c 100644 --- a/packages/manager/src/components/DiskEncryption/constants.tsx +++ b/packages/manager/src/components/DiskEncryption/constants.tsx @@ -23,5 +23,8 @@ export const DISK_ENCRYPTION_BACKUPS_CAVEAT_COPY = export const DISK_ENCRYPTION_NODE_POOL_GUIDANCE_COPY = 'To enable disk encryption, delete the node pool and create a new node pool. New node pools are always encrypted.'; +export const UNENCRYPTED_STANDARD_LINODE_GUIDANCE_COPY = + 'Rebuild this Linode to enable or disable disk encryption.'; + export const DISK_ENCRYPTION_IMAGES_CAVEAT_COPY = 'Virtual Machine Images are not encrypted.'; diff --git a/packages/manager/src/factories/linodes.ts b/packages/manager/src/factories/linodes.ts index 63f5b0e8ad0..735dac98182 100644 --- a/packages/manager/src/factories/linodes.ts +++ b/packages/manager/src/factories/linodes.ts @@ -263,6 +263,7 @@ export const linodeFactory = Factory.Sync.makeFactory({ ipv4: ['50.116.6.212', '192.168.203.1'], ipv6: '2600:3c00::f03c:92ff:fee2:6c40/64', label: Factory.each((i) => `linode-${i}`), + lke_cluster_id: null, placement_group: placementGroupFactory.build({ affinity_type: 'anti_affinity:local', id: 1, diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx index be95c8ab1df..3ba84624448 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable.tsx @@ -214,8 +214,10 @@ export const EncryptedStatus = ({ ) : encryptionStatus === 'disabled' ? ( <> - - Not Encrypted + + + Not Encrypted + {tooltipText ? : null} ) : null; diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx index 54c368bab8d..981cca15e03 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.test.tsx @@ -13,6 +13,7 @@ import { HttpResponse, http, server } from 'src/mocks/testServer'; import { queryClientFactory } from 'src/queries/base'; import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; +import { encryptionStatusTestId } from '../Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable'; import { LinodeEntityDetail } from './LinodeEntityDetail'; import { getSubnetsString } from './LinodeEntityDetailBody'; import { LinodeHandlers } from './LinodesLanding/LinodesLanding'; @@ -34,6 +35,29 @@ describe('Linode Entity Detail', () => { const vpcSectionTestId = 'vpc-section-title'; const assignedVPCLabelTestId = 'assigned-vpc-label'; + const mocks = vi.hoisted(() => { + return { + useIsDiskEncryptionFeatureEnabled: vi.fn(), + }; + }); + + vi.mock('src/components/DiskEncryption/utils.ts', async () => { + const actual = await vi.importActual( + 'src/components/DiskEncryption/utils.ts' + ); + return { + ...actual, + __esModule: true, + useIsDiskEncryptionFeatureEnabled: mocks.useIsDiskEncryptionFeatureEnabled.mockImplementation( + () => { + return { + isDiskEncryptionFeatureEnabled: false, // indicates the feature flag is off or account capability is absent + }; + } + ), + }; + }); + it('should not display the VPC section if the linode is not assigned to a VPC', async () => { const account = accountFactory.build({ capabilities: [...accountCapabilitiesWithoutVPC, 'VPCs'], @@ -104,6 +128,41 @@ describe('Linode Entity Detail', () => { expect(getByTestId(assignedVPCLabelTestId).innerHTML).toEqual('test-vpc'); }); }); + + it('should not display the encryption status of the linode if the account lacks the capability or the feature flag is off', () => { + // situation where isDiskEncryptionFeatureEnabled === false + const { queryByTestId } = renderWithTheme( + + ); + const encryptionStatusFragment = queryByTestId(encryptionStatusTestId); + + expect(encryptionStatusFragment).not.toBeInTheDocument(); + }); + + it('should display the encryption status of the linode when Disk Encryption is enabled and the user has the account capability', () => { + mocks.useIsDiskEncryptionFeatureEnabled.mockImplementationOnce(() => { + return { + isDiskEncryptionFeatureEnabled: true, + }; + }); + + const { queryByTestId } = renderWithTheme( + + ); + const encryptionStatusFragment = queryByTestId(encryptionStatusTestId); + + expect(encryptionStatusFragment).toBeInTheDocument(); + }); }); describe('getSubnetsString function', () => { diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx index ffcaf1e4a7b..bdef9ccca51 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.tsx @@ -111,10 +111,12 @@ export const LinodeEntityDetail = (props: Props) => { body={ { const { configInterfaceWithVPC, + encryptionStatus, gbRAM, gbStorage, ipv4, ipv6, + isLKELinode, isVPCOnlyLinode, linodeId, linodeIsInDistributedRegion, @@ -77,6 +93,14 @@ export const LinodeEntityDetailBody = React.memo((props: BodyProps) => { const theme = useTheme(); + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); + + // @ TODO LDE: Remove usages of this variable once LDE is fully rolled out (being used to determine formatting adjustments currently) + const isDisplayingEncryptedStatus = + isDiskEncryptionFeatureEnabled && Boolean(encryptionStatus); + // Filter and retrieve subnets associated with a specific Linode ID const linodeAssociatedSubnets = vpcLinodeIsAssignedTo?.subnets.filter( (subnet) => subnet.linodes.some((linode) => linode.id === linodeId) @@ -97,11 +121,14 @@ export const LinodeEntityDetailBody = React.memo((props: BodyProps) => { - + Summary @@ -121,9 +148,28 @@ export const LinodeEntityDetailBody = React.memo((props: BodyProps) => { {pluralize('Volume', 'Volumes', numVolumes)}
    + {isDiskEncryptionFeatureEnabled && encryptionStatus && ( + + + + + + )} - + { ipv6={linode.ipv6 || ''} key={`linode-row-${1}`} label={linode.label} + lke_cluster_id={linode.lke_cluster_id} placement_group={linode.placement_group} region={linode.region} specs={linode.specs} diff --git a/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx b/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx index ac316735313..d8b35d2adf8 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/ListView.tsx @@ -46,6 +46,8 @@ export const ListView = (props: RenderLinodesProps) => { ipv6={linode.ipv6 || ''} key={`linode-row-${idx}`} label={linode.label} + lke_cluster_id={linode.lke_cluster_id} + maintenance={linode.maintenance} placement_group={linode.placement_group} region={linode.region} specs={linode.specs} @@ -54,7 +56,6 @@ export const ListView = (props: RenderLinodesProps) => { type={linode.type} updated={linode.updated} watchdog_enabled={linode.watchdog_enabled} - maintenance={linode.maintenance} /> ))} From 684c38cf9baca6297676923710f5d51fca04ea52 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Wed, 5 Jun 2024 08:16:02 -0400 Subject: [PATCH 053/163] test: [M3-8108, M3-8113] - Improve security of Linodes created by tests (#10538) * Use `createTestLinode` to create Linodes, remove deprecated util * Improve security of test Linode creation * Add utils to find or create Firewall for test Linodes * Fix failing tests by specifying a security method that is compatible with the test flow * Temporarily skip Longview test * Add changeset --- .../pr-10538-tests-1717187190983.md | 5 + .../e2e/core/account/service-transfer.spec.ts | 13 +- .../core/firewalls/create-firewall.spec.ts | 14 +- .../migrate-linode-with-firewall.spec.ts | 10 +- .../e2e/core/linodes/backup-linode.spec.ts | 6 +- .../e2e/core/linodes/clone-linode.spec.ts | 6 +- .../e2e/core/linodes/linode-config.spec.ts | 9 +- .../e2e/core/linodes/linode-storage.spec.ts | 12 +- .../e2e/core/linodes/rebuild-linode.spec.ts | 9 +- .../e2e/core/linodes/rescue-linode.spec.ts | 4 +- .../e2e/core/linodes/resize-linode.spec.ts | 164 +++++++++--------- .../core/linodes/smoke-delete-linode.spec.ts | 12 +- .../core/linodes/switch-linode-state.spec.ts | 32 +++- .../core/linodes/update-linode-labels.spec.ts | 11 +- .../e2e/core/longview/longview.spec.ts | 8 +- .../smoke-create-nodebal.spec.ts | 63 ++++--- .../stackscripts/create-stackscripts.spec.ts | 7 +- .../smoke-community-stackscrips.spec.ts | 2 +- .../e2e/core/volumes/attach-volume.spec.ts | 7 +- .../e2e/core/volumes/create-volume.spec.ts | 98 ++++++----- .../create-machine-image-from-linode.spec.ts | 35 +--- .../e2e/region/linodes/delete-linode.spec.ts | 5 +- .../e2e/region/linodes/update-linode.spec.ts | 9 +- .../manager/cypress/support/api/firewalls.ts | 64 ++++++- .../manager/cypress/support/api/linodes.ts | 49 +----- .../cypress/support/constants/cypress.ts | 2 +- .../manager/cypress/support/util/linodes.ts | 90 +++++++--- 27 files changed, 427 insertions(+), 319 deletions(-) create mode 100644 packages/manager/.changeset/pr-10538-tests-1717187190983.md diff --git a/packages/manager/.changeset/pr-10538-tests-1717187190983.md b/packages/manager/.changeset/pr-10538-tests-1717187190983.md new file mode 100644 index 00000000000..13ed2f1b676 --- /dev/null +++ b/packages/manager/.changeset/pr-10538-tests-1717187190983.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Improve test Linode security ([#10538](https://github.com/linode/manager/pull/10538)) diff --git a/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts b/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts index 7a71dfe0fb9..9268d097c88 100644 --- a/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts +++ b/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts @@ -2,7 +2,6 @@ * @file Tests for service transfer functionality between accounts. */ -import { createLinode } from '@linode/api-v4/lib/linodes'; import { getProfile } from '@linode/api-v4/lib/profile'; import { EntityTransfer, Linode, Profile } from '@linode/api-v4'; import { entityTransferFactory } from 'src/factories/entityTransfers'; @@ -19,6 +18,7 @@ import { } from 'support/intercepts/account'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; +import { createTestLinode } from 'support/util/linodes'; import { pollLinodeStatus } from 'support/util/polling'; import { randomLabel, randomUuid } from 'support/util/random'; import { visitUrlWithManagedEnabled } from 'support/api/managed'; @@ -244,15 +244,18 @@ describe('Account service transfers', () => { * - Confirms that users can cancel a service transfer */ it('can initiate and cancel a service transfer', () => { - // Create a Linode to transfer and wait for it to boot. + // Create a Linode to transfer. const setupLinode = async (): Promise => { const payload = createLinodeRequestFactory.build({ label: randomLabel(), region: chooseRegion().id, }); - const linode: Linode = await createLinode(payload); - await pollLinodeStatus(linode.id, 'running', { + const linode: Linode = await createTestLinode(payload, { + securityMethod: 'powered_off', + }); + + await pollLinodeStatus(linode.id, 'offline', { initialDelay: 15000, }); @@ -320,7 +323,7 @@ describe('Account service transfers', () => { cy.get('[data-qa-close-drawer]').should('be.visible').click(); }); - // Attempt to receive the an invalid token. + // Attempt to receive an invalid token. redeemToken(randomUuid()); assertReceiptError('Not found'); diff --git a/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts index 43a92c8c3ca..298417a1a72 100644 --- a/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts @@ -1,7 +1,6 @@ -import { createLinode } from '@linode/api-v4/lib/linodes'; +import { createTestLinode } from 'support/util/linodes'; import { createLinodeRequestFactory } from 'src/factories/linodes'; import { authenticate } from 'support/api/authentication'; -import { containsClick, getClick } from 'support/helpers'; import { interceptCreateFirewall } from 'support/intercepts/firewalls'; import { randomString, randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; @@ -33,10 +32,10 @@ describe('create firewall', () => { .should('be.visible') .within(() => { // An error message appears when attempting to create a Firewall without a label - getClick('[data-testid="submit"]'); + cy.get('[data-testid="submit"]').click(); cy.findByText('Label is required.'); // Fill out and submit firewall create form. - containsClick('Label').type(firewall.label); + cy.contains('Label').click().type(firewall.label); ui.buttonGroup .findButtonByTitle('Create Firewall') .should('be.visible') @@ -75,7 +74,10 @@ describe('create firewall', () => { label: randomLabel(), }; - cy.defer(createLinode(linodeRequest), 'creating Linode').then((linode) => { + cy.defer( + createTestLinode(linodeRequest, { securityMethod: 'powered_off' }), + 'creating Linode' + ).then((linode) => { interceptCreateFirewall().as('createFirewall'); cy.visitWithLogin('/firewalls/create'); @@ -84,7 +86,7 @@ describe('create firewall', () => { .should('be.visible') .within(() => { // Fill out and submit firewall create form. - containsClick('Label').type(firewall.label); + cy.contains('Label').click().type(firewall.label); cy.findByLabelText('Linodes') .should('be.visible') .click() diff --git a/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts index 8763f5bd8fe..2ff4bd31f67 100644 --- a/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts @@ -6,7 +6,6 @@ import { regionFactory, } from '@src/factories'; import { authenticate } from 'support/api/authentication'; -import { createLinode } from '@linode/api-v4'; import { interceptCreateFirewall, interceptGetFirewalls, @@ -23,6 +22,7 @@ import { cleanUp } from 'support/util/cleanup'; import { randomLabel, randomNumber } from 'support/util/random'; import type { Linode, Region } from '@linode/api-v4'; import { chooseRegions } from 'support/util/regions'; +import { createTestLinode } from 'support/util/linodes'; const mockRegions: Region[] = [ regionFactory.build({ @@ -66,7 +66,7 @@ const migrationNoticeSubstrings = [ authenticate(); describe('Migrate Linode With Firewall', () => { before(() => { - cleanUp('firewalls'); + cleanUp(['firewalls', 'linodes']); }); /* @@ -144,7 +144,9 @@ describe('Migrate Linode With Firewall', () => { interceptGetFirewalls().as('getFirewalls'); // Create a Linode, then navigate to the Firewalls landing page. - cy.defer(createLinode(linodePayload)).then((linode: Linode) => { + cy.defer( + createTestLinode(linodePayload, { securityMethod: 'powered_off' }) + ).then((linode: Linode) => { interceptMigrateLinode(linode.id).as('migrateLinode'); cy.visitWithLogin('/firewalls'); cy.wait('@getFirewalls'); @@ -194,7 +196,7 @@ describe('Migrate Linode With Firewall', () => { // Make sure Linode is running before attempting to migrate. cy.get('[data-qa-linode-status]').within(() => { - cy.findByText('RUNNING'); + cy.findByText('OFFLINE'); }); ui.actionMenu diff --git a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts index 01694c1fa4a..4fd976daa48 100644 --- a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts @@ -1,6 +1,5 @@ /* eslint-disable sonarjs/no-duplicate-string */ import type { Linode } from '@linode/api-v4'; -import { createLinode } from '@linode/api-v4'; import { linodeFactory, linodeBackupsFactory, @@ -27,6 +26,7 @@ import { randomLabel } from 'support/util/random'; import { dcPricingMockLinodeTypesForBackups } from 'support/constants/dc-specific-pricing'; import { chooseRegion } from 'support/util/regions'; import { expectManagedDisabled } from 'support/api/managed'; +import { createTestLinode } from 'support/util/linodes'; authenticate(); describe('linode backups', () => { @@ -53,7 +53,7 @@ describe('linode backups', () => { booted: false, }); - cy.defer(createLinode(createLinodeRequest), 'creating Linode').then( + cy.defer(createTestLinode(createLinodeRequest), 'creating Linode').then( (linode: Linode) => { interceptGetLinode(linode.id).as('getLinode'); interceptEnableLinodeBackups(linode.id).as('enableBackups'); @@ -116,7 +116,7 @@ describe('linode backups', () => { const snapshotName = randomLabel(); - cy.defer(createLinode(createLinodeRequest), 'creating Linode').then( + cy.defer(createTestLinode(createLinodeRequest), 'creating Linode').then( (linode: Linode) => { interceptGetLinode(linode.id).as('getLinode'); interceptCreateLinodeSnapshot(linode.id).as('createSnapshot'); diff --git a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts index 3e4901afb86..afc1c505ac4 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -1,4 +1,3 @@ -import { Linode, createLinode } from '@linode/api-v4'; import { linodeFactory, createLinodeRequestFactory } from '@src/factories'; import { interceptCloneLinode, @@ -18,6 +17,8 @@ import { chooseRegion, getRegionById } from 'support/util/regions'; import { randomLabel } from 'support/util/random'; import { authenticate } from 'support/api/authentication'; import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; +import type { Linode } from '@linode/api-v4'; /** * Returns the Cloud Manager URL to clone a given Linode. @@ -49,12 +50,13 @@ describe('clone linode', () => { region: linodeRegion.id, // Specifying no image allows the Linode to provision and clone faster. image: undefined, + booted: false, type: 'g6-nanode-1', }); const newLinodeLabel = `${linodePayload.label}-clone`; - cy.defer(createLinode(linodePayload)).then((linode: Linode) => { + cy.defer(createTestLinode(linodePayload)).then((linode: Linode) => { const linodeRegion = getRegionById(linodePayload.region!); interceptCloneLinode(linode.id).as('cloneLinode'); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts index 51b32e4ca54..81ec20da2d5 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -234,7 +234,10 @@ describe('Linode Config management', () => { */ it('Boots a config', () => { cy.defer( - createLinodeAndGetConfig(null, { waitForBoot: true }), + createLinodeAndGetConfig( + { booted: true }, + { waitForBoot: true, securityMethod: 'vlan_no_internet' } + ), 'Creating and booting test Linode' ).then(([linode, config]: [Linode, Config]) => { const kernel = findKernelById(kernels, config.kernel); @@ -281,8 +284,8 @@ describe('Linode Config management', () => { // Create clone source and destination Linodes. const createCloneTestLinodes = async () => { return Promise.all([ - createTestLinode(null, { waitForBoot: true }), - createTestLinode(), + createTestLinode({ booted: true }, { waitForBoot: true }), + createTestLinode({ booted: true }), ]); }; diff --git a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts index 41c59858748..de3d7603a40 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts @@ -1,7 +1,7 @@ /* eslint-disable sonarjs/no-duplicate-string */ import { Linode } from '@linode/api-v4'; import { authenticate } from 'support/api/authentication'; -import { createLinode } from 'support/api/linodes'; +import { createTestLinode } from 'support/util/linodes'; import { containsVisible, fbtClick, fbtVisible } from 'support/helpers'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; @@ -103,8 +103,8 @@ describe('linode storage tab', () => { }); it('try to delete in use disk', () => { - const diskName = 'Debian 10 Disk'; - createLinode().then((linode) => { + const diskName = 'Debian 11 Disk'; + cy.defer(createTestLinode({ booted: true })).then((linode) => { cy.intercept( 'DELETE', apiMatcher(`linode/instances/${linode.id}/disks/*`) @@ -127,7 +127,7 @@ describe('linode storage tab', () => { it('delete disk', () => { const diskName = 'cy-test-disk'; - createLinode({ image: null }).then((linode: Linode) => { + cy.defer(createTestLinode({ image: null })).then((linode) => { cy.intercept( 'DELETE', apiMatcher(`linode/instances/${linode.id}/disks/*`) @@ -157,7 +157,7 @@ describe('linode storage tab', () => { it('add a disk', () => { const diskName = 'cy-test-disk'; - createLinode({ image: null }).then((linode: Linode) => { + cy.defer(createTestLinode({ image: null })).then((linode: Linode) => { cy.intercept( 'POST', apiMatcher(`/linode/instances/${linode.id}/disks`) @@ -171,7 +171,7 @@ describe('linode storage tab', () => { it('resize disk', () => { const diskName = 'Debian 10 Disk'; - createLinode({ image: null }).then((linode: Linode) => { + cy.defer(createTestLinode({ image: null })).then((linode: Linode) => { cy.intercept( 'POST', apiMatcher(`linode/instances/${linode.id}/disks`) diff --git a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts index 55b5e951243..0e6f00e2ffa 100644 --- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts @@ -1,4 +1,4 @@ -import { createLinode, CreateLinodeRequest, Linode } from '@linode/api-v4'; +import { CreateLinodeRequest, Linode } from '@linode/api-v4'; import { ui } from 'support/ui'; import { randomString, randomLabel } from 'support/util/random'; import { authenticate } from 'support/api/authentication'; @@ -12,6 +12,7 @@ import { mockGetLinodeDetails, mockRebuildLinodeError, } from 'support/intercepts/linodes'; +import { createTestLinode } from 'support/util/linodes'; /** * Creates a Linode and StackScript. @@ -27,7 +28,7 @@ const createStackScriptAndLinode = async ( ) => { return Promise.all([ createStackScript(stackScriptRequestPayload), - createLinode(linodeRequestPayload), + createTestLinode(linodeRequestPayload), ]); }; @@ -117,7 +118,7 @@ describe('rebuild linode', () => { region: chooseRegion().id, }); - cy.defer(createLinode(linodeCreatePayload), 'creating Linode').then( + cy.defer(createTestLinode(linodeCreatePayload), 'creating Linode').then( (linode: Linode) => { interceptRebuildLinode(linode.id).as('linodeRebuild'); @@ -171,7 +172,7 @@ describe('rebuild linode', () => { region: chooseRegion().id, }); - cy.defer(createLinode(linodeCreatePayload), 'creating Linode').then( + cy.defer(createTestLinode(linodeCreatePayload), 'creating Linode').then( (linode: Linode) => { interceptRebuildLinode(linode.id).as('linodeRebuild'); interceptGetStackScripts().as('getStackScripts'); diff --git a/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts index 2d62584aa8e..658de76da84 100644 --- a/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts @@ -1,5 +1,4 @@ import type { Linode } from '@linode/api-v4'; -import { createLinode } from '@linode/api-v4'; import { createLinodeRequestFactory, linodeFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { @@ -12,6 +11,7 @@ import { } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; import { randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; @@ -43,7 +43,7 @@ describe('Rescue Linodes', () => { region: chooseRegion().id, }); - cy.defer(createLinode(linodePayload), 'creating Linode').then( + cy.defer(createTestLinode(linodePayload), 'creating Linode').then( (linode: Linode) => { interceptGetLinodeDetails(linode.id).as('getLinode'); interceptRebootLinodeIntoRescueMode(linode.id).as( diff --git a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts index d55affe595c..69e0f3980b0 100644 --- a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts @@ -1,4 +1,4 @@ -import { createLinode } from 'support/api/linodes'; +import { createTestLinode } from 'support/util/linodes'; import { containsVisible, fbtVisible, getClick } from 'support/helpers'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; @@ -15,7 +15,7 @@ describe('resize linode', () => { it('resizes a linode by increasing size: warm migration', () => { mockGetFeatureFlagClientstream().as('getClientStream'); - createLinode().then((linode) => { + cy.defer(createTestLinode({ booted: true })).then((linode) => { interceptLinodeResize(linode.id).as('linodeResize'); cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); cy.findByText('Shared CPU').click({ scrollBehavior: false }); @@ -35,7 +35,7 @@ describe('resize linode', () => { it('resizes a linode by increasing size: cold migration', () => { mockGetFeatureFlagClientstream().as('getClientStream'); - createLinode().then((linode) => { + cy.defer(createTestLinode({ booted: true })).then((linode) => { interceptLinodeResize(linode.id).as('linodeResize'); cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); cy.findByText('Shared CPU').click({ scrollBehavior: false }); @@ -56,7 +56,7 @@ describe('resize linode', () => { it('resizes a linode by increasing size when offline: cold migration', () => { mockGetFeatureFlagClientstream().as('getClientStream'); - createLinode().then((linode) => { + cy.defer(createTestLinode({ booted: true })).then((linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); // Turn off the linode to resize the disk @@ -97,83 +97,87 @@ describe('resize linode', () => { }); }); - it('resizes a linode by decreasing size', () => { - createLinode().then((linode) => { - const diskName = 'Debian 10 Disk'; - const size = '50000'; // 50 GB - - // Error flow when attempting to resize a linode to a smaller size without - // resizing the disk to the requested size first. - interceptLinodeResize(linode.id).as('linodeResize'); - cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); - cy.findByText('Shared CPU').click({ scrollBehavior: false }); - containsVisible('Linode 2 GB'); - getClick('[id="g6-standard-1"]'); - cy.get('[data-testid="textfield-input"]').type(linode.label); - cy.get('[data-qa-resize="true"]').should('be.enabled').click(); - cy.wait('@linodeResize'); - // Failed to reduce the size of the linode - cy.contains( - 'The current disk size of your Linode is too large for the new service plan. Please resize your disk to accommodate the new plan. You can read our Resize Your Linode guide for more detailed instructions.' - ) - .scrollIntoView() - .should('be.visible'); - - // Normal flow when resizing a linode to a smaller size after first resizing - // its disk. - cy.visitWithLogin(`/linodes/${linode.id}`); - - // Turn off the linode to resize the disk - ui.button.findByTitle('Power Off').should('be.visible').click(); - - ui.dialog - .findByTitle(`Power Off Linode ${linode.label}?`) - .should('be.visible') - .then(() => { - ui.button - .findByTitle(`Power Off Linode`) - .should('be.visible') - .click(); + it.only('resizes a linode by decreasing size', () => { + cy.defer(createTestLinode({ booted: true, type: 'g6-standard-2' })).then( + (linode) => { + const diskName = 'Debian 11 Disk'; + const size = '50000'; // 50 GB + + // Error flow when attempting to resize a linode to a smaller size without + // resizing the disk to the requested size first. + interceptLinodeResize(linode.id).as('linodeResize'); + cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); + cy.findByText('Shared CPU').click({ scrollBehavior: false }); + containsVisible('Linode 2 GB'); + getClick('[id="g6-standard-1"]'); + cy.get('[data-testid="textfield-input"]').type(linode.label); + cy.get('[data-qa-resize="true"]').should('be.enabled').click(); + cy.wait('@linodeResize'); + // Failed to reduce the size of the linode + cy.contains( + 'The current disk size of your Linode is too large for the new service plan. Please resize your disk to accommodate the new plan. You can read our Resize Your Linode guide for more detailed instructions.' + ) + .scrollIntoView() + .should('be.visible'); + + // Normal flow when resizing a linode to a smaller size after first resizing + // its disk. + cy.visitWithLogin(`/linodes/${linode.id}`); + + // Turn off the linode to resize the disk + ui.button.findByTitle('Power Off').should('be.visible').click(); + + ui.dialog + .findByTitle(`Power Off Linode ${linode.label}?`) + .should('be.visible') + .then(() => { + ui.button + .findByTitle(`Power Off Linode`) + .should('be.visible') + .click(); + }); + + containsVisible('OFFLINE'); + + cy.visitWithLogin(`linodes/${linode.id}/storage`); + fbtVisible(diskName); + + cy.get(`[data-qa-disk="${diskName}"]`).within(() => { + cy.contains('Resize').should('be.enabled').click(); }); - containsVisible('OFFLINE'); - - cy.visitWithLogin(`linodes/${linode.id}/storage`); - fbtVisible(diskName); - - cy.get(`[data-qa-disk="${diskName}"]`).within(() => { - cy.contains('Resize').should('be.enabled').click(); - }); - - ui.drawer.findByTitle(`Resize Debian 10 Disk`); - - ui.drawer - .findByTitle(`Resize ${diskName}`) - .should('be.visible') - .within(() => { - cy.get('[id="size"]').should('be.visible').click().clear().type(size); - - ui.buttonGroup - .findButtonByTitle('Resize') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Wait until the disk resize is done. - ui.toast.assertMessage(`Disk ${diskName} successfully resized.`); - - interceptLinodeResize(linode.id).as('linodeResize'); - cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); - cy.findByText('Shared CPU').click({ scrollBehavior: false }); - containsVisible('Linode 2 GB'); - getClick('[id="g6-standard-1"]'); - cy.get('[data-testid="textfield-input"]').type(linode.label); - cy.get('[data-qa-resize="true"]').should('be.enabled').click(); - cy.wait('@linodeResize'); - cy.contains( - 'Your Linode will soon be automatically powered off, migrated, and restored to its previous state (booted or powered off).' - ).should('be.visible'); - }); + ui.drawer + .findByTitle(`Resize ${diskName}`) + .should('be.visible') + .within(() => { + cy.get('[id="size"]') + .should('be.visible') + .click() + .clear() + .type(size); + + ui.buttonGroup + .findButtonByTitle('Resize') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Wait until the disk resize is done. + ui.toast.assertMessage(`Disk ${diskName} successfully resized.`); + + interceptLinodeResize(linode.id).as('linodeResize'); + cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); + cy.findByText('Shared CPU').click({ scrollBehavior: false }); + containsVisible('Linode 2 GB'); + getClick('[id="g6-standard-1"]'); + cy.get('[data-testid="textfield-input"]').type(linode.label); + cy.get('[data-qa-resize="true"]').should('be.enabled').click(); + cy.wait('@linodeResize'); + cy.contains( + 'Your Linode will soon be automatically powered off, migrated, and restored to its previous state (booted or powered off).' + ).should('be.visible'); + } + ); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts index 403ff604712..cac96713e76 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts @@ -1,5 +1,5 @@ import { authenticate } from 'support/api/authentication'; -import { createLinode } from '@linode/api-v4/lib/linodes'; +import { createTestLinode } from 'support/util/linodes'; import { createLinodeRequestFactory } from '@src/factories/linodes'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; @@ -73,7 +73,7 @@ describe('delete linode', () => { const linodeCreatePayload = createLinodeRequestFactory.build({ label: randomLabel(), }); - cy.defer(createLinode(linodeCreatePayload)).then((linode) => { + cy.defer(createTestLinode(linodeCreatePayload)).then((linode) => { // catch delete request interceptDeleteLinode(linode.id).as('deleteLinode'); cy.visitWithLogin(`/linodes/${linode.id}`); @@ -120,7 +120,7 @@ describe('delete linode', () => { const linodeCreatePayload = createLinodeRequestFactory.build({ label: randomLabel(), }); - cy.defer(createLinode(linodeCreatePayload)).then((linode) => { + cy.defer(createTestLinode(linodeCreatePayload)).then((linode) => { // catch delete request interceptDeleteLinode(linode.id).as('deleteLinode'); cy.visitWithLogin(`/linodes/${linode.id}`); @@ -171,7 +171,7 @@ describe('delete linode', () => { const linodeCreatePayload = createLinodeRequestFactory.build({ label: randomLabel(), }); - cy.defer(createLinode(linodeCreatePayload)).then((linode) => { + cy.defer(createTestLinode(linodeCreatePayload)).then((linode) => { // catch delete request interceptDeleteLinode(linode.id).as('deleteLinode'); cy.visitWithLogin(`/linodes`); @@ -219,10 +219,10 @@ describe('delete linode', () => { const createTwoLinodes = async (): Promise<[Linode, Linode]> => { return Promise.all([ - createLinode( + createTestLinode( createLinodeRequestFactory.build({ label: randomLabel() }) ), - createLinode( + createTestLinode( createLinodeRequestFactory.build({ label: randomLabel() }) ), ]); diff --git a/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts b/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts index 848aeb2fc87..ec8cbeab797 100644 --- a/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts @@ -17,7 +17,13 @@ describe('switch linode state', () => { * - Does not wait for Linode to finish being shut down before succeeding. */ it('powers off a linode from landing page', () => { - cy.defer(createTestLinode()).then((linode: Linode) => { + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to reboot shortly after booting up when the Linode is + // attached to a Cloud Firewall. + cy.defer( + createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) + ).then((linode: Linode) => { cy.visitWithLogin('/linodes'); cy.get(`[data-qa-linode="${linode.label}"]`) .should('be.visible') @@ -58,7 +64,13 @@ describe('switch linode state', () => { * - Waits for Linode to fully shut down before succeeding. */ it('powers off a linode from details page', () => { - cy.defer(createTestLinode()).then((linode: Linode) => { + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to reboot shortly after booting up when the Linode is + // attached to a Cloud Firewall. + cy.defer( + createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) + ).then((linode: Linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); cy.contains('RUNNING').should('be.visible'); cy.findByText(linode.label).should('be.visible'); @@ -156,7 +168,13 @@ describe('switch linode state', () => { * - Does not wait for Linode to finish rebooting before succeeding. */ it('reboots a linode from landing page', () => { - cy.defer(createTestLinode()).then((linode: Linode) => { + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to reboot shortly after booting up when the Linode is + // attached to a Cloud Firewall. + cy.defer( + createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) + ).then((linode: Linode) => { cy.visitWithLogin('/linodes'); cy.get(`[data-qa-linode="${linode.label}"]`) .should('be.visible') @@ -197,7 +215,13 @@ describe('switch linode state', () => { * - Waits for Linode to finish rebooting before succeeding. */ it('reboots a linode from details page', () => { - cy.defer(createTestLinode()).then((linode: Linode) => { + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to reboot shortly after booting up when the Linode is + // attached to a Cloud Firewall. + cy.defer( + createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) + ).then((linode: Linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); cy.contains('RUNNING').should('be.visible'); cy.findByText(linode.label).should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts b/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts index ee944990f44..31e0ed477b6 100644 --- a/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts @@ -1,5 +1,4 @@ -import { createLinode } from 'support/api/linodes'; -import { containsVisible } from 'support/helpers'; +import { createTestLinode } from 'support/util/linodes'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { authenticate } from 'support/api/authentication'; @@ -12,10 +11,10 @@ describe('update linode label', () => { }); it('updates a linode label from details page', () => { - createLinode().then((linode) => { + cy.defer(createTestLinode({ booted: true })).then((linode) => { const newLinodeLabel = randomLabel(); cy.visitWithLogin(`/linodes/${linode.id}`); - containsVisible('RUNNING'); + cy.contains('RUNNING').should('be.visible'); cy.get(`[aria-label="Edit ${linode.label}"]`).click(); cy.get(`[id="edit-${linode.label}-label"]`) @@ -29,10 +28,10 @@ describe('update linode label', () => { }); it('updates a linode label from the "Settings" tab', () => { - createLinode().then((linode) => { + cy.defer(createTestLinode({ booted: true })).then((linode) => { const newLinodeLabel = randomLabel(); cy.visitWithLogin(`/linodes/${linode.id}`); - containsVisible('RUNNING'); + cy.contains('RUNNING').should('be.visible'); cy.visitWithLogin(`/linodes/${linode.id}/settings`); cy.get('[id="label"]').click().clear().type(`${newLinodeLabel}{enter}`); diff --git a/packages/manager/cypress/e2e/core/longview/longview.spec.ts b/packages/manager/cypress/e2e/core/longview/longview.spec.ts index 06c8ee71061..ebccb894f55 100644 --- a/packages/manager/cypress/e2e/core/longview/longview.spec.ts +++ b/packages/manager/cypress/e2e/core/longview/longview.spec.ts @@ -18,7 +18,7 @@ import { } from 'support/intercepts/longview'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; -import { createAndBootLinode } from 'support/util/linodes'; +import { createTestLinode } from 'support/util/linodes'; import { randomLabel, randomString } from 'support/util/random'; // Timeout if Linode creation and boot takes longer than 1 and a half minutes. @@ -111,7 +111,8 @@ describe('longview', () => { * - Creates a Linode, connects to it via SSH, and installs Longview using the given cURL command. * - Confirms that Cloud Manager UI updates to reflect Longview installation and data. */ - it('can install Longview client on a Linode', () => { + // TODO Unskip for M3-8107. + it.skip('can install Longview client on a Linode', () => { const linodePassword = randomString(32, { symbols: false, lowercase: true, @@ -122,9 +123,10 @@ describe('longview', () => { const createLinodeAndClient = async () => { return Promise.all([ - createAndBootLinode({ + createTestLinode({ root_pass: linodePassword, type: 'g6-standard-1', + booted: true, }), createLongviewClient(randomLabel()), ]); diff --git a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts index 0672c1accd1..3cd493d1c1b 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts @@ -1,12 +1,5 @@ import { entityTag } from 'support/constants/cypress'; -import { createLinode } from 'support/api/linodes'; -import { - containsClick, - fbtClick, - fbtVisible, - getClick, - getVisible, -} from 'support/helpers'; +import { createTestLinode } from 'support/util/linodes'; import { randomLabel } from 'support/util/random'; import { chooseRegion, getRegionById } from 'support/util/regions'; @@ -34,8 +27,12 @@ const createNodeBalancerWithUI = ( const regionName = getRegionById(nodeBal.region).label; cy.visitWithLogin('/nodebalancers/create'); - getVisible('[id="nodebalancer-label"]').click().clear().type(nodeBal.label); - containsClick('create a tag').type(entityTag); + cy.get('[id="nodebalancer-label"]') + .should('be.visible') + .click() + .clear() + .type(nodeBal.label); + cy.contains('create a tag').click().type(entityTag); if (isDcPricingTest) { const newRegion = getRegionById('br-gru'); @@ -66,7 +63,7 @@ const createNodeBalancerWithUI = ( ui.regionSelect.find().click().clear().type(`${regionName}{enter}`); // node backend config - fbtClick('Label').type(randomLabel()); + cy.findByText('Label').click().type(randomLabel()); cy.findByLabelText('IP Address') .should('be.visible') @@ -85,9 +82,14 @@ describe('create NodeBalancer', () => { }); it('creates a NodeBalancer in a region with base pricing', () => { - // create a linode in NW where the NB will be created const region = chooseRegion(); - createLinode({ region: region.id }).then((linode) => { + const linodePayload = { + region: region.id, + // NodeBalancers require Linodes with private IPs. + private_ip: true, + }; + + cy.defer(createTestLinode(linodePayload)).then((linode) => { const nodeBal = nodeBalancerFactory.build({ label: randomLabel(), region: region.id, @@ -109,7 +111,12 @@ describe('create NodeBalancer', () => { */ it('displays API errors for NodeBalancer Create form fields', () => { const region = chooseRegion(); - createLinode({ region: region.id }).then((linode) => { + const linodePayload = { + region: region.id, + // NodeBalancers require Linodes with private IPs. + private_ip: true, + }; + cy.defer(createTestLinode(linodePayload)).then((linode) => { const nodeBal = nodeBalancerFactory.build({ label: `${randomLabel()}-^`, ipv4: linode.ipv4[1], @@ -120,15 +127,21 @@ describe('create NodeBalancer', () => { interceptCreateNodeBalancer().as('createNodeBalancer'); createNodeBalancerWithUI(nodeBal); - fbtVisible(`Label can't contain special characters or spaces.`); - getVisible('[id="nodebalancer-label"]') + cy.findByText(`Label can't contain special characters or spaces.`).should( + 'be.visible' + ); + cy.get('[id="nodebalancer-label"]') + .should('be.visible') .click() .clear() .type(randomLabel()); - getClick('[data-qa-protocol-select="true"]').type('TCP{enter}'); - getClick('[data-qa-session-stickiness-select]').type( - 'HTTP Cookie{enter}' - ); + + cy.get('[data-qa-protocol-select="true"]').click().type('TCP{enter}'); + + cy.get('[data-qa-session-stickiness-select]') + .click() + .type('HTTP Cookie{enter}'); + deployNodeBalancer(); const errMessage = `Stickiness http_cookie requires protocol 'http' or 'https'`; cy.wait('@createNodeBalancer') @@ -136,7 +149,8 @@ describe('create NodeBalancer', () => { .should('deep.equal', { errors: [{ field: 'configs[0].stickiness', reason: errMessage }], }); - fbtVisible(errMessage); + + cy.findByText(errMessage).should('be.visible'); }); }); @@ -146,7 +160,12 @@ describe('create NodeBalancer', () => { */ it('shows DC-specific pricing information when creating a NodeBalancer', () => { const initialRegion = getRegionById('us-west'); - createLinode({ region: initialRegion.id }).then((linode) => { + const linodePayload = { + region: initialRegion.id, + // NodeBalancers require Linodes with private IPs. + private_ip: true, + }; + cy.defer(createTestLinode(linodePayload)).then((linode) => { const nodeBal = nodeBalancerFactory.build({ label: randomLabel(), region: initialRegion.id, diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index 1edfb9845ca..23587b19b95 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -12,12 +12,11 @@ import { import { interceptCreateLinode } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; import { createLinodeRequestFactory } from 'src/factories'; -import { createLinode, getLinodeDisks } from '@linode/api-v4/lib/linodes'; -import { createImage } from '@linode/api-v4/lib/images'; +import { createImage, getLinodeDisks, resizeLinodeDisk } from '@linode/api-v4'; import { chooseRegion } from 'support/util/regions'; import { SimpleBackoffMethod } from 'support/util/backoff'; import { cleanUp } from 'support/util/cleanup'; -import { resizeLinodeDisk } from '@linode/api-v4/lib'; +import { createTestLinode } from 'support/util/linodes'; // StackScript fixture paths. const stackscriptBasicPath = 'stackscripts/stackscript-basic.sh'; @@ -113,7 +112,7 @@ const createLinodeAndImage = async () => { // 1.5GB // Shout out to Debian for fitting on a 1.5GB disk. const resizedDiskSize = 1536; - const linode = await createLinode( + const linode = await createTestLinode( createLinodeRequestFactory.build({ label: randomLabel(), region: chooseRegion().id, diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts index 2ebd072b40e..7d827e662ad 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts @@ -11,7 +11,7 @@ import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { cleanUp } from 'support/util/cleanup'; import { interceptCreateLinode } from 'support/intercepts/linodes'; -import { getProfile } from '@linode/api-v4/lib'; +import { getProfile } from '@linode/api-v4'; import { Profile, StackScript } from '@linode/api-v4'; import { formatDate } from '@src/utilities/formatDate'; diff --git a/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts index 42adfdfd0ec..647a53a42cd 100644 --- a/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts @@ -1,4 +1,3 @@ -import { createLinode } from '@linode/api-v4/lib/linodes'; import { createVolume } from '@linode/api-v4/lib/volumes'; import { Linode, Volume } from '@linode/api-v4'; import { createLinodeRequestFactory } from 'src/factories/linodes'; @@ -13,6 +12,7 @@ import { ui } from 'support/ui'; import { chooseRegion } from 'support/util/regions'; import { interceptGetLinodeConfigs } from 'support/intercepts/configs'; import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; // Local storage override to force volume table to list up to 100 items. // This is a workaround while we wait to get stuck volumes removed. @@ -48,7 +48,7 @@ const pageSizeOverride = { authenticate(); describe('volume attach and detach flows', () => { before(() => { - cleanUp('volumes'); + cleanUp(['volumes', 'linodes']); }); /* @@ -66,11 +66,12 @@ describe('volume attach and detach flows', () => { label: randomLabel(), region: commonRegion.id, root_pass: randomString(32), + booted: false, }); const entityPromise = Promise.all([ createVolume(volumeRequest), - createLinode(linodeRequest), + createTestLinode(linodeRequest), ]); cy.defer(entityPromise, 'creating Volume and Linode').then( diff --git a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts index 5304208c626..6131cd87cde 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts @@ -1,5 +1,5 @@ import type { Linode } from '@linode/api-v4'; -import { createLinode } from '@linode/api-v4/lib/linodes'; +import { createTestLinode } from 'support/util/linodes'; import { createLinodeRequestFactory } from 'src/factories/linodes'; import { authenticate } from 'support/api/authentication'; import { cleanUp } from 'support/util/cleanup'; @@ -76,6 +76,7 @@ describe('volume create flow', () => { label: randomLabel(), region: region.id, root_pass: randomString(16), + booted: false, }); const volume = { @@ -85,54 +86,56 @@ describe('volume create flow', () => { regionLabel: region.label, }; - cy.defer(createLinode(linodeRequest), 'creating Linode').then((linode) => { - interceptCreateVolume().as('createVolume'); + cy.defer(createTestLinode(linodeRequest), 'creating Linode').then( + (linode) => { + interceptCreateVolume().as('createVolume'); - cy.visitWithLogin('/volumes/create', { - localStorageOverrides: pageSizeOverride, - }); - - // Fill out and submit volume create form. - containsClick('Label').type(volume.label); - containsClick('Size').type(`{selectall}{backspace}${volume.size}`); - ui.regionSelect.find().click().type(`${volume.region}{enter}`); - - cy.findByLabelText('Linode') - .should('be.visible') - .click() - .type(linode.label); - - ui.autocompletePopper - .findByTitle(linode.label) - .should('be.visible') - .click(); - - fbtClick('Create Volume'); - cy.wait('@createVolume'); - - // Confirm volume configuration drawer opens, then close it. - fbtVisible('Volume scheduled for creation.'); - getClick('[data-qa-close-drawer="true"]'); - - // Confirm that volume is listed on landing page with expected configuration. - cy.findByText(volume.label) - .closest('tr') - .within(() => { - cy.findByText(volume.label).should('be.visible'); - cy.findByText(`${volume.size} GB`).should('be.visible'); - cy.findByText(volume.regionLabel).should('be.visible'); - cy.findByText(linode.label).should('be.visible'); + cy.visitWithLogin('/volumes/create', { + localStorageOverrides: pageSizeOverride, }); - // Confirm that volume is listed on Linode 'Storage' details page. - cy.visitWithLogin(`/linodes/${linode.id}/storage`); - cy.findByText(volume.label) - .closest('tr') - .within(() => { - fbtVisible(volume.label); - fbtVisible(`${volume.size} GB`); - }); - }); + // Fill out and submit volume create form. + containsClick('Label').type(volume.label); + containsClick('Size').type(`{selectall}{backspace}${volume.size}`); + ui.regionSelect.find().click().type(`${volume.region}{enter}`); + + cy.findByLabelText('Linode') + .should('be.visible') + .click() + .type(linode.label); + + ui.autocompletePopper + .findByTitle(linode.label) + .should('be.visible') + .click(); + + fbtClick('Create Volume'); + cy.wait('@createVolume'); + + // Confirm volume configuration drawer opens, then close it. + fbtVisible('Volume scheduled for creation.'); + getClick('[data-qa-close-drawer="true"]'); + + // Confirm that volume is listed on landing page with expected configuration. + cy.findByText(volume.label) + .closest('tr') + .within(() => { + cy.findByText(volume.label).should('be.visible'); + cy.findByText(`${volume.size} GB`).should('be.visible'); + cy.findByText(volume.regionLabel).should('be.visible'); + cy.findByText(linode.label).should('be.visible'); + }); + + // Confirm that volume is listed on Linode 'Storage' details page. + cy.visitWithLogin(`/linodes/${linode.id}/storage`); + cy.findByText(volume.label) + .closest('tr') + .within(() => { + fbtVisible(volume.label); + fbtVisible(`${volume.size} GB`); + }); + } + ); }); /* @@ -145,9 +148,10 @@ describe('volume create flow', () => { label: randomLabel(), root_pass: randomString(16), region: chooseRegion().id, + booted: false, }); - cy.defer(createLinode(linodeRequest), 'creating Linode').then( + cy.defer(createTestLinode(linodeRequest), 'creating Linode').then( (linode: Linode) => { const volume = { label: randomLabel(), diff --git a/packages/manager/cypress/e2e/region/images/create-machine-image-from-linode.spec.ts b/packages/manager/cypress/e2e/region/images/create-machine-image-from-linode.spec.ts index e324a416e03..dacc00ad54c 100644 --- a/packages/manager/cypress/e2e/region/images/create-machine-image-from-linode.spec.ts +++ b/packages/manager/cypress/e2e/region/images/create-machine-image-from-linode.spec.ts @@ -1,43 +1,17 @@ -import type { CreateLinodeRequest, Disk, Linode } from '@linode/api-v4'; -import { createLinode, getLinodeDisks } from '@linode/api-v4'; +import type { Disk, Linode } from '@linode/api-v4'; +import { createTestLinode } from 'support/util/linodes'; import { createLinodeRequestFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { imageCaptureProcessingTimeout } from 'support/constants/images'; import { ui } from 'support/ui'; -import { SimpleBackoffMethod } from 'support/util/backoff'; import { cleanUp } from 'support/util/cleanup'; -import { depaginate } from 'support/util/paginate'; -import { pollLinodeStatus } from 'support/util/polling'; import { randomLabel, randomPhrase, randomString } from 'support/util/random'; import { testRegions } from 'support/util/regions'; -/** - * Creates a Linode, waits for it to boot, and returns the Linode and its disk. - * - * @param linodePayload - Linode create API request payload. - * - * @returns Promise that resolves to a tuple containing the created Linode and its disk. - */ -const createAndBootLinode = async ( - linodePayload: CreateLinodeRequest -): Promise<[Linode, Disk]> => { - const linode = await createLinode(linodePayload); - // Wait 25 seconds to begin polling, then poll every 5 seconds until Linode boots. - await pollLinodeStatus( - linode.id, - 'running', - new SimpleBackoffMethod(5000, { - initialDelay: 25000, - }) - ); - const disks = await depaginate((page) => getLinodeDisks(linode.id, { page })); - return [linode, disks[0]]; -}; - authenticate(); describe('Capture Machine Images', () => { before(() => { - cleanUp('images'); + cleanUp(['images', 'linodes']); }); /* @@ -54,10 +28,11 @@ describe('Capture Machine Images', () => { label: randomLabel(), root_pass: randomString(32), region: region.id, + booted: true, }); cy.defer( - createAndBootLinode(linodePayload), + createTestLinode(linodePayload, { waitForBoot: true }), 'creating and booting Linode' ).then(([linode, disk]: [Linode, Disk]) => { cy.visitWithLogin('/images/create/disk'); diff --git a/packages/manager/cypress/e2e/region/linodes/delete-linode.spec.ts b/packages/manager/cypress/e2e/region/linodes/delete-linode.spec.ts index 0c92f09ce01..dec88cbe6cc 100644 --- a/packages/manager/cypress/e2e/region/linodes/delete-linode.spec.ts +++ b/packages/manager/cypress/e2e/region/linodes/delete-linode.spec.ts @@ -2,7 +2,6 @@ import { createLinodeRequestFactory } from '@src/factories'; import { describeRegions } from 'support/util/regions'; import { randomLabel, randomString } from 'support/util/random'; import { Region } from '@linode/api-v4'; -import { createLinode } from '@linode/api-v4'; import type { Linode } from '@linode/api-v4'; import { ui } from 'support/ui'; import { authenticate } from 'support/api/authentication'; @@ -11,6 +10,7 @@ import { interceptGetLinodes, } from 'support/intercepts/linodes'; import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; authenticate(); describeRegions('Delete Linodes', (region: Region) => { @@ -28,11 +28,12 @@ describeRegions('Delete Linodes', (region: Region) => { label: randomLabel(), region: region.id, root_pass: randomString(32), + booted: false, }); // Create a Linode before navigating to its details page to delete it. cy.defer( - createLinode(linodeCreatePayload), + createTestLinode(linodeCreatePayload), `creating Linode in ${region.label}` ).then((linode: Linode) => { interceptGetLinodeDetails(linode.id).as('getLinode'); diff --git a/packages/manager/cypress/e2e/region/linodes/update-linode.spec.ts b/packages/manager/cypress/e2e/region/linodes/update-linode.spec.ts index a62e9e7bdd5..6850c680e42 100644 --- a/packages/manager/cypress/e2e/region/linodes/update-linode.spec.ts +++ b/packages/manager/cypress/e2e/region/linodes/update-linode.spec.ts @@ -1,5 +1,5 @@ import type { Disk, Linode } from '@linode/api-v4'; -import { createLinode, getLinodeDisks } from '@linode/api-v4'; +import { getLinodeDisks } from '@linode/api-v4'; import { createLinodeRequestFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { interceptGetLinodeDetails } from 'support/intercepts/linodes'; @@ -8,6 +8,7 @@ import { cleanUp } from 'support/util/cleanup'; import { depaginate } from 'support/util/paginate'; import { randomLabel, randomString } from 'support/util/random'; import { describeRegions } from 'support/util/regions'; +import { createTestLinode } from 'support/util/linodes'; /* * Returns a Linode create payload for the given region. @@ -34,7 +35,7 @@ describeRegions('Can update Linodes', (region) => { */ it('can update a Linode label', () => { cy.defer( - createLinode(makeLinodePayload(region.id, true)), + createTestLinode(makeLinodePayload(region.id, true)), 'creating Linode' ).then((linode: Linode) => { const newLabel = randomLabel(); @@ -89,7 +90,9 @@ describeRegions('Can update Linodes', (region) => { const newPassword = randomString(32); const createLinodeAndGetDisk = async (): Promise<[Linode, Disk]> => { - const linode = await createLinode(makeLinodePayload(region.id, false)); + const linode = await createTestLinode( + makeLinodePayload(region.id, false) + ); const disks = await depaginate((page) => getLinodeDisks(linode.id, { page }) ); diff --git a/packages/manager/cypress/support/api/firewalls.ts b/packages/manager/cypress/support/api/firewalls.ts index 4c48bf3c16d..5a31465b87d 100644 --- a/packages/manager/cypress/support/api/firewalls.ts +++ b/packages/manager/cypress/support/api/firewalls.ts @@ -1,9 +1,71 @@ -import { Firewall, deleteFirewall, getFirewalls } from '@linode/api-v4'; +import { + Firewall, + deleteFirewall, + getFirewalls, + createFirewall, + FirewallRules, +} from '@linode/api-v4'; import { pageSize } from 'support/constants/api'; import { depaginate } from 'support/util/paginate'; +import { randomLabel } from 'support/util/random'; import { isTestLabel } from './common'; +/** + * Determines if Firewall rules are sufficiently locked down to use for a test resource. + * + * Returns `true` if the rules have default inbound and outbound policies to + * drop connections and do not have any additional rules. + * + * @param rules - Firewall rules to assess. + * + * @returns `true` if Firewall rules are locked down, `false` otherwise. + */ +export const areFirewallRulesLockedDown = (rules: FirewallRules) => { + const { outbound, outbound_policy, inbound, inbound_policy } = rules; + + const hasOutboundRules = !!outbound && outbound.length > 0; + const hasInboundRules = !!inbound && inbound.length > 0; + + return ( + outbound_policy === 'DROP' && + inbound_policy === 'DROP' && + !hasInboundRules && + !hasOutboundRules + ); +}; + +/** + * Returns a firewall to use for a test resource, creating it if one does not already exist. + * + * @returns Promise that resolves to existing or new Firewall. + */ +export const findOrCreateDependencyFirewall = async () => { + const firewalls = await depaginate((page: number) => + getFirewalls({ page, page_size: pageSize }) + ); + + const suitableFirewalls = firewalls.filter( + ({ label, rules }: Firewall) => + isTestLabel(label) && areFirewallRulesLockedDown(rules) + ); + + if (suitableFirewalls.length > 0) { + return suitableFirewalls[0]; + } + + // No suitable firewalls exist, so we'll create one and return it. + return createFirewall({ + label: randomLabel(), + rules: { + inbound: [], + outbound: [], + inbound_policy: 'DROP', + outbound_policy: 'DROP', + }, + }); +}; + /** * Deletes all Firewalls whose labels are prefixed "cy-test-". * diff --git a/packages/manager/cypress/support/api/linodes.ts b/packages/manager/cypress/support/api/linodes.ts index 6865519b8ac..9fbe30f3302 100644 --- a/packages/manager/cypress/support/api/linodes.ts +++ b/packages/manager/cypress/support/api/linodes.ts @@ -1,14 +1,10 @@ import { Linode, deleteLinode, getLinodes } from '@linode/api-v4'; -import { CreateLinodeRequest } from '@linode/api-v4'; import { linodeFactory } from '@src/factories'; import { makeResourcePage } from '@src/mocks/serverHandlers'; -import { oauthToken, pageSize } from 'support/constants/api'; -import { entityTag } from 'support/constants/cypress'; +import { pageSize } from 'support/constants/api'; import { depaginate } from 'support/util/paginate'; -import { randomLabel, randomString } from 'support/util/random'; -import { chooseRegion } from 'support/util/regions'; -import { apiCheckErrors, deleteById, isTestLabel } from './common'; +import { deleteById, isTestLabel } from './common'; export const createMockLinodeList = (data?: {}, listNumber: number = 1) => { return makeResourcePage( @@ -18,47 +14,6 @@ export const createMockLinodeList = (data?: {}, listNumber: number = 1) => { ); }; -const defaultLinodeRequestBody = { - authorized_users: [], - backups_enabled: false, - booted: true, - image: 'linode/debian10', - private_ip: true, - region: chooseRegion().id, - root_pass: randomString(32), - tags: [entityTag], - type: 'g6-standard-2', -}; - -const linodeRequest = (linodeData: CreateLinodeRequest) => { - return cy.request({ - auth: { - bearer: oauthToken, - }, - body: linodeData, - method: 'POST', - url: Cypress.env('REACT_APP_API_ROOT') + '/linode/instances', - }); -}; - -export const requestBody = (data: Partial) => { - const label = randomLabel(); - return linodeRequest({ label, ...defaultLinodeRequestBody, ...data }); -}; - -/** - * Deprecated. Use `createTestLinode()` with `cy.defer()` instead. - * - * @deprecated - */ -export const createLinode = (data = {}) => { - return requestBody(data).then((resp) => { - apiCheckErrors(resp); - console.log(`Created Linode ${resp.body.label} successfully`, resp); - return resp.body; - }); -}; - export const deleteLinodeById = (linodeId: number) => deleteById('linode/instances', linodeId); diff --git a/packages/manager/cypress/support/constants/cypress.ts b/packages/manager/cypress/support/constants/cypress.ts index 3df9ce61a59..c5ab5c563da 100644 --- a/packages/manager/cypress/support/constants/cypress.ts +++ b/packages/manager/cypress/support/constants/cypress.ts @@ -3,7 +3,7 @@ */ /** - * Tag to use to identify test entities, resources, etc. + * Tag to identify test entities, resources, etc. */ export const entityTag = 'cy-test'; diff --git a/packages/manager/cypress/support/util/linodes.ts b/packages/manager/cypress/support/util/linodes.ts index 68d33007a7b..76186a2ccc0 100644 --- a/packages/manager/cypress/support/util/linodes.ts +++ b/packages/manager/cypress/support/util/linodes.ts @@ -2,13 +2,29 @@ import { createLinode, getLinodeConfigs } from '@linode/api-v4'; import { createLinodeRequestFactory } from '@src/factories'; import { SimpleBackoffMethod } from 'support/util/backoff'; import { pollLinodeDiskStatuses, pollLinodeStatus } from 'support/util/polling'; -import { randomLabel } from 'support/util/random'; +import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { depaginate } from './paginate'; import { pageSize } from 'support/constants/api'; -import type { Config, Linode } from '@linode/api-v4'; -import type { CreateLinodeRequest } from '@linode/api-v4'; +import type { Config, CreateLinodeRequest, Linode } from '@linode/api-v4'; +import { findOrCreateDependencyFirewall } from 'support/api/firewalls'; + +/** + * Methods used to secure test Linodes. + * + * - `firewall`: A firewall is used to secure the created Linode. If a suitable + * firewall does not exist, one is created first. + * + * - `vlan_no_internet`: The created Linode's `eth0` network interface is set to + * a VLAN, and no public internet interface is configured. + * + * - `powered_off`: The created Linode is not booted upon creation. + */ +export type CreateTestLinodeSecurityMethod = + | 'firewall' + | 'vlan_no_internet' + | 'powered_off'; /** * Options to control the behavior of test Linode creation. @@ -19,6 +35,9 @@ export interface CreateTestLinodeOptions { /** Whether to wait for created Linode to boot before resolving. */ waitForBoot: boolean; + + /** Method to use to secure the test Linode. */ + securityMethod: CreateTestLinodeSecurityMethod; } /** @@ -27,6 +46,7 @@ export interface CreateTestLinodeOptions { export const defaultCreateTestLinodeOptions = { waitForDisks: false, waitForBoot: false, + securityMethod: 'firewall', }; /** @@ -46,13 +66,52 @@ export const createTestLinode = async ( ...(options || {}), }; + const securityMethodPayload: Partial = await (async () => { + switch (resolvedOptions.securityMethod) { + case 'firewall': + default: + const firewall = await findOrCreateDependencyFirewall(); + return { + firewall_id: firewall.id, + }; + + case 'vlan_no_internet': + return { + interfaces: [ + { + purpose: 'vlan', + primary: false, + label: randomLabel(), + ipam_address: null, + }, + ], + }; + + case 'powered_off': + return { + booted: false, + }; + } + })(); + const resolvedCreatePayload = { ...createLinodeRequestFactory.build({ label: randomLabel(), image: 'linode/debian11', region: chooseRegion().id, + booted: false, }), ...(createRequestPayload || {}), + ...securityMethodPayload, + + // Override given root password; mitigate against using default factory password, inadvertent logging, etc. + root_pass: randomString(64, { + spaces: true, + symbols: true, + numbers: true, + lowercase: true, + uppercase: true, + }), }; // Display warnings for certain combinations of options/request payloads... @@ -105,7 +164,10 @@ export const createTestLinode = async ( consoleProps: () => { return { options: resolvedOptions, - payload: resolvedCreatePayload, + payload: { + ...resolvedCreatePayload, + root_pass: '(redacted)', + }, linode, }; }, @@ -114,26 +176,6 @@ export const createTestLinode = async ( return linode; }; -/** - * Creates a Linode and waits for it to be in "running" state. - * - * Deprecated. Use `createTestLinode` with `waitForBoot` set to `true`. - * - * @param createPayload - Optional Linode create payload options. - * - * @deprecated - * - * @returns Promis that resolves when Linode is created and booted. - */ -export const createAndBootLinode = async ( - createPayload?: Partial -): Promise => { - console.warn( - '`createAndBootLinode()` is deprecated. Use `createTestLinode()` instead.' - ); - return createTestLinode(createPayload, { waitForBoot: true }); -}; - /** * Retrieves all Config objects belonging to a Linode. * From c13f58e9b0080d5d1a118351860031e81f0648cc Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Wed, 5 Jun 2024 14:03:02 -0400 Subject: [PATCH 054/163] change: [M3-8159] - Modify limited availability banner display logic (#10536) * Modify limited availability banner display logic * Update class copy * Added changeset: Modify limited availability banner display logic * Add comments * Update packages/manager/src/features/components/PlansPanel/PlansPanel.tsx Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * feedback @mjac0bs --------- Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- .../pr-10536-changed-1717183226858.md | 5 + .../e2e/core/linodes/plan-selection.spec.ts | 62 ++++---- .../KubernetesPlansPanel.tsx | 3 +- .../PlansPanel/PlanInformation.test.tsx | 4 +- .../components/PlansPanel/PlanInformation.tsx | 136 +++++++----------- .../components/PlansPanel/PlansPanel.tsx | 15 +- .../features/components/PlansPanel/utils.ts | 1 - 7 files changed, 105 insertions(+), 121 deletions(-) create mode 100644 packages/manager/.changeset/pr-10536-changed-1717183226858.md diff --git a/packages/manager/.changeset/pr-10536-changed-1717183226858.md b/packages/manager/.changeset/pr-10536-changed-1717183226858.md new file mode 100644 index 00000000000..d7d6297562e --- /dev/null +++ b/packages/manager/.changeset/pr-10536-changed-1717183226858.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Modify limited availability banner display logic ([#10536](https://github.com/linode/manager/pull/10536)) diff --git a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts index 86838505220..5a6480c18ec 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -44,6 +44,11 @@ const mockDedicatedLinodeTypes = [ label: 'dedicated-3', class: 'dedicated', }), + linodeTypeFactory.build({ + id: 'dedicated-4', + label: 'dedicated-4', + class: 'dedicated', + }), ]; const mockSharedLinodeTypes = [ @@ -98,11 +103,21 @@ const mockRegionAvailability = [ available: false, region: 'us-east', }), + regionAvailabilityFactory.build({ + plan: 'dedicated-4', + available: false, + region: 'us-east', + }), regionAvailabilityFactory.build({ plan: 'highmem-1', available: false, region: 'us-east', }), + regionAvailabilityFactory.build({ + plan: 'shared-3', + available: false, + region: 'us-east', + }), ]; const linodePlansPanel = '[data-qa-tp="Linode Plan"]'; @@ -110,7 +125,7 @@ const k8PlansPanel = '[data-qa-tp="Add Node Pools"]'; const planSelectionTable = 'List of Linode Plans'; const notices = { - limitedAvailability: '[data-testid="disabled-plan-tooltip"]', + limitedAvailability: '[data-testid="limited-availability-banner"]', unavailable: '[data-testid="notice-error"]', }; @@ -136,15 +151,15 @@ describe('displays linode plans panel based on availability', () => { // Dedicated CPU tab // Should be selected/open by default // Should have the limited availability notice - // Should contain 4 plans (5 rows including the header row) - // Should have 2 plans disabled - // Should have tooltips for the disabled plans (not more than half disabled plans in the panel) + // Should contain 5 plans (6 rows including the header row) + // Should have 3 plans disabled + // Should not have tooltips for the disabled plans (more than half disabled plans in the panel) cy.get(linodePlansPanel).within(() => { cy.findAllByRole('alert').should('have.length', 1); cy.get(notices.limitedAvailability).should('be.visible'); cy.findByRole('table', { name: planSelectionTable }).within(() => { - cy.findAllByRole('row').should('have.length', 5); + cy.findAllByRole('row').should('have.length', 6); cy.get('[id="dedicated-1"]').should('be.enabled'); cy.get('[id="dedicated-2"]').should('be.enabled'); cy.get( @@ -152,15 +167,15 @@ describe('displays linode plans panel based on availability', () => { ); cy.get('[id="dedicated-3"]').should('be.disabled'); cy.get('[id="g6-dedicated-64"]').should('be.disabled'); - cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 2); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); }); }); // Shared CPU tab // Should have no notices // Should contain 3 plans (4 rows including the header row) - // Should have 0 disabled plan - // Should have no tooltip for the disabled plan + // Should have 1 disabled plan + // Should have one tooltip for the disabled plan cy.findByText('Shared CPU').click(); cy.get(linodePlansPanel).within(() => { cy.findAllByRole('alert').should('have.length', 0); @@ -169,8 +184,8 @@ describe('displays linode plans panel based on availability', () => { cy.findAllByRole('row').should('have.length', 4); cy.get('[id="shared-1"]').should('be.enabled'); cy.get('[id="shared-2"]').should('be.enabled'); - cy.get('[id="shared-3"]').should('be.enabled'); - cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); + cy.get('[id="shared-3"]').should('be.disabled'); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 1); }); }); @@ -178,7 +193,7 @@ describe('displays linode plans panel based on availability', () => { // Should have the limited availability notice // Should contain 1 plan (2 rows including the header row) // Should have one disabled plan - // Should have tooltip for the disabled plan (more than half disabled plans in the panel, but only one plan) + // Should have no tooltip for the disabled plan (more than half disabled plans in the panel) cy.findByText('High Memory').click(); cy.get(linodePlansPanel).within(() => { cy.findAllByRole('alert').should('have.length', 1); @@ -187,7 +202,7 @@ describe('displays linode plans panel based on availability', () => { cy.findByRole('table', { name: planSelectionTable }).within(() => { cy.findAllByRole('row').should('have.length', 2); cy.get('[id="highmem-1"]').should('be.disabled'); - cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 1); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); }); }); @@ -232,9 +247,9 @@ describe('displays kubernetes plans panel based on availability', () => { // Dedicated CPU tab // Should be selected/open by default // Should have the limited availability notice - // Should contain 4 plans (5 rows including the header row) - // Should have 2 plans disabled - // Should have tooltips for the disabled plans (not more than half disabled plans in the panel) + // Should contain 5 plans (6 rows including the header row) + // Should have 3 plans disabled + // Should have no tooltips for the disabled plans (more than half disabled plans in the panel) // All inputs for a row should be enabled if row is enabled (only testing one row in suite) // All inputs for a disabled row should be disabled (only testing one row in suite) cy.get(k8PlansPanel).within(() => { @@ -242,7 +257,7 @@ describe('displays kubernetes plans panel based on availability', () => { cy.get(notices.limitedAvailability).should('be.visible'); cy.findByRole('table', { name: planSelectionTable }).within(() => { - cy.findAllByRole('row').should('have.length', 5); + cy.findAllByRole('row').should('have.length', 6); cy.get('[data-qa-plan-row="dedicated-1"]').should( 'not.have.attr', 'disabled' @@ -270,14 +285,14 @@ describe('displays kubernetes plans panel based on availability', () => { ) .should('be.disabled'); }); - cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 2); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); }); }); // Shared CPU tab // Should have no notices // Should contain 3 plans (4 rows including the header row) - // Should have 1 disabled plan + // Should have 2 disabled plans // Should have tooltip for the disabled plan (not more than half disabled plans in the panel) cy.findByText('Shared CPU').click(); cy.get(k8PlansPanel).within(() => { @@ -293,11 +308,8 @@ describe('displays kubernetes plans panel based on availability', () => { 'not.have.attr', 'disabled' ); - cy.get('[data-qa-plan-row="shared-3"]').should( - 'not.have.attr', - 'disabled' - ); - cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); + cy.get('[data-qa-plan-row="shared-3"]').should('have.attr', 'disabled'); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 1); }); }); @@ -305,7 +317,7 @@ describe('displays kubernetes plans panel based on availability', () => { // Should have the limited availability notice // Should contain 1 plan (2 rows including the header row) // Should have one disabled plan - // Should have tooltip for the disabled plan (more than half disabled plans in the panel, but only one plan) + // Should have no tooltip for the disabled plan (more than half disabled plans in the panel) cy.findByText('High Memory').click(); cy.get(k8PlansPanel).within(() => { cy.findAllByRole('alert').should('have.length', 1); @@ -317,7 +329,7 @@ describe('displays kubernetes plans panel based on availability', () => { 'have.attr', 'disabled' ); - cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 1); + cy.findAllByTestId('disabled-plan-tooltip').should('have.length', 0); }); }); diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx index 6660f315360..fffc1efc25b 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx @@ -76,7 +76,6 @@ export const KubernetesPlansPanel = (props: Props) => { const plansMap: PlanSelectionType[] = plans[plan]; const { allDisabledPlans, - hasDisabledPlans, hasMajorityOfPlansDisabled, plansForThisLinodeTypeClass, } = extractPlansInformation({ @@ -94,7 +93,7 @@ export const KubernetesPlansPanel = (props: Props) => { isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan( plan )} - hasDisabledPlans={hasDisabledPlans} + hasMajorityOfPlansDisabled={hasMajorityOfPlansDisabled} hasSelectedRegion={hasSelectedRegion} planType={plan} regionsData={regionsData} diff --git a/packages/manager/src/features/components/PlansPanel/PlanInformation.test.tsx b/packages/manager/src/features/components/PlansPanel/PlanInformation.test.tsx index a48c0fcff31..089a59d27ad 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanInformation.test.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanInformation.test.tsx @@ -11,7 +11,7 @@ import { import type { PlanInformationProps } from './PlanInformation'; const mockProps: PlanInformationProps = { - hasDisabledPlans: false, + hasMajorityOfPlansDisabled: false, hasSelectedRegion: true, isSelectedRegionEligibleForPlan: false, planType: 'standard', @@ -38,7 +38,7 @@ describe('PlanInformation', () => { renderWithTheme( diff --git a/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx b/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx index 399074560ce..2f9bd20478d 100644 --- a/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlanInformation.tsx @@ -22,7 +22,7 @@ import type { Region } from '@linode/api-v4'; export interface PlanInformationProps { disabledClasses?: LinodeTypeClass[]; - hasDisabledPlans: boolean; + hasMajorityOfPlansDisabled: boolean; hasSelectedRegion: boolean; hideLimitedAvailabilityBanner?: boolean; isSelectedRegionEligibleForPlan: boolean; @@ -31,10 +31,9 @@ export interface PlanInformationProps { } export const PlanInformation = (props: PlanInformationProps) => { - const theme = useTheme(); const { disabledClasses, - hasDisabledPlans, + hasMajorityOfPlansDisabled, hasSelectedRegion, hideLimitedAvailabilityBanner, isSelectedRegionEligibleForPlan, @@ -72,111 +71,72 @@ export const PlanInformation = (props: PlanInformationProps) => { ) : null} {hasSelectedRegion && isSelectedRegionEligibleForPlan && - !hideLimitedAvailabilityBanner && ( - + !hideLimitedAvailabilityBanner && + hasMajorityOfPlansDisabled && ( + ({ + marginBottom: theme.spacing(3), + marginLeft: 0, + marginTop: 0, + padding: `${theme.spacing(0.5)} ${theme.spacing(2)}`, + })} + dataTestId={limitedAvailabilityBannerTestId} + variant="warning" + > + + These plans have limited deployment availability. + + )} - - {planTabInfoContent[planType]?.typography} - + ); }; export const limitedAvailabilityBannerTestId = 'limited-availability-banner'; -interface LimitedAvailabilityNoticeProps { - hasDisabledPlans: boolean; +interface ClassDescriptionCopyProps { planType: 'shared' | LinodeTypeClass; } -export const LimitedAvailabilityNotice = ( - props: LimitedAvailabilityNoticeProps -) => { - const { hasDisabledPlans, planType } = props; +export const ClassDescriptionCopy = (props: ClassDescriptionCopyProps) => { + const { planType } = props; + const theme = useTheme(); + let planTypeLabel: null | string; + let docLink: null | string; switch (planType) { case 'dedicated': - return ( - - ); - + planTypeLabel = 'Dedicated CPU'; + docLink = DEDICATED_COMPUTE_INSTANCES_LINK; + break; case 'shared': - return ( - - ); - + planTypeLabel = 'Shared CPU'; + docLink = SHARED_COMPUTE_INSTANCES_LINK; + break; case 'highmem': - return ( - - ); - + planTypeLabel = 'High Memory'; + docLink = HIGH_MEMORY_COMPUTE_INSTANCES_LINK; + break; case 'premium': - return ( - - ); - + planTypeLabel = 'Premium CPU'; + docLink = PREMIUM_COMPUTE_INSTANCES_LINK; + break; case 'gpu': - return ( - - ); - + planTypeLabel = 'GPU'; + docLink = GPU_COMPUTE_INSTANCES_LINK; + break; default: - return null; + planTypeLabel = null; + docLink = null; } -}; - -interface LimitedAvailabilityNoticeCopyProps { - docsLink: string; - hasDisabledPlans: boolean; - planTypeLabel: string; -} - -export const LimitedAvailabilityNoticeCopy = ( - props: LimitedAvailabilityNoticeCopyProps -) => { - const { docsLink, hasDisabledPlans, planTypeLabel } = props; - return hasDisabledPlans ? ( - ({ - marginBottom: theme.spacing(3), - marginLeft: 0, - marginTop: 0, - padding: `${theme.spacing(0.5)} ${theme.spacing(2)}`, - })} - dataTestId={limitedAvailabilityBannerTestId} - variant="warning" + return planTypeLabel && docLink ? ( + - - These plans have limited deployment availability.{' '} - Learn more about our {planTypeLabel} plans. - - + {planTabInfoContent[planType]?.typography}{' '} + Learn more about our {planTypeLabel} plans. + ) : null; }; diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx index b516c11e678..432a1670a0c 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx @@ -48,6 +48,14 @@ export interface PlansPanelProps { types: PlanSelectionType[]; } +/** + * PlansPanel is a tabbed panel that displays a list of plans for a Linode. + * It is used in the Linode create, Kubernetes and Database create flows. + * It contains ample logic to determine which plans are available based on the selected region availability and display related visual indicators: + * - If the region is not supported, show an error notice and disable all plans. + * - If more than half the plans are disabled, show the limited availability banner and hide the limited availability tooltip + * - If less than half the plans are disabled, hide the limited availability banner and show the limited availability tooltip + */ export const PlansPanel = (props: PlansPanelProps) => { const { className, @@ -119,7 +127,6 @@ export const PlansPanel = (props: PlansPanelProps) => { const plansMap: PlanSelectionType[] = plans[plan]; const { allDisabledPlans, - hasDisabledPlans, hasMajorityOfPlansDisabled, plansForThisLinodeTypeClass, } = extractPlansInformation({ @@ -138,13 +145,15 @@ export const PlansPanel = (props: PlansPanelProps) => { <> 0; const hasMajorityOfPlansDisabled = - plans.length !== 1 && allDisabledPlans.length > plansForThisLinodeTypeClass.length / 2; return { From b78edb7a0acb9aba729a11fb96ea99ec1869e175 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 5 Jun 2024 14:03:18 -0400 Subject: [PATCH 055/163] refactor: [M3-7437] - Query Key Factory for Security Questions and Preferences (#10543) * use factory for security questions and preferences * Added changeset: Query Key Factory for Security Questions and Preferences * try to fix unit tests * fix typo @abailly-akamai --------- Co-authored-by: Banks Nussman --- .../pr-10543-tech-stories-1717514648816.md | 5 ++ packages/manager/src/MainContent.tsx | 7 +- .../AccessPanel/UserSSHKeyPanel.tsx | 2 +- .../DateTimeDisplay/DateTimeDisplay.tsx | 2 +- .../DeletionDialog/DeletionDialog.tsx | 2 +- .../src/components/MainContentBanner.tsx | 5 +- .../MaintenanceBanner/MaintenanceBanner.tsx | 2 +- packages/manager/src/components/OrderBy.tsx | 5 +- .../PreferenceToggle/PreferenceToggle.tsx | 5 +- .../manager/src/components/TagCell/AddTag.tsx | 2 +- .../src/components/TagsInput/TagsInput.tsx | 2 +- .../TypeToConfirmDialog.tsx | 2 +- .../src/containers/preferences.container.ts | 5 +- .../src/containers/profile.container.ts | 2 +- .../src/features/Account/AccountLanding.tsx | 2 +- .../src/features/Account/AccountLogins.tsx | 2 +- .../Account/AccountLoginsTableRow.tsx | 2 +- .../features/Account/CloseAccountDialog.tsx | 2 +- .../Account/CloseAccountSetting.test.tsx | 4 +- .../features/Account/CloseAccountSetting.tsx | 2 +- .../features/Account/EnableObjectStorage.tsx | 2 +- .../Maintenance/MaintenanceTableRow.tsx | 2 +- .../src/features/Backups/BackupsCTA.tsx | 7 +- .../src/features/Billing/BillingDetail.tsx | 2 +- .../BillingActivityPanel.tsx | 2 +- .../BillingSummary/BillingSummary.tsx | 2 +- .../PaymentDrawer/PaymentDrawer.tsx | 2 +- .../ContactInformation.test.tsx | 4 +- .../UpdateContactInformationForm.tsx | 2 +- .../AddPaymentMethodDrawer.tsx | 2 +- .../PaymentInformation.test.tsx | 4 +- .../RestoreFromBackupDialog.tsx | 2 +- .../DatabaseSettings/DatabaseSettings.tsx | 2 +- .../Databases/DatabaseLanding/DatabaseRow.tsx | 2 +- .../features/Domains/CloneDomainDrawer.tsx | 2 +- .../Domains/CreateDomain/CreateDomain.tsx | 2 +- .../Domains/DomainZoneImportDrawer.tsx | 2 +- .../src/features/Domains/DomainsLanding.tsx | 2 +- .../src/features/Domains/EditDomainDrawer.tsx | 2 +- .../ConfirmTransferDialog.tsx | 2 +- .../Devices/AddLinodeDrawer.tsx | 2 +- .../Devices/AddNodebalancerDrawer.tsx | 2 +- .../Firewalls/FirewallDetail/index.tsx | 2 +- .../FirewallLanding/CreateFirewallDrawer.tsx | 2 +- .../FirewallLanding/FirewallActionMenu.tsx | 2 +- .../GlobalNotifications/EmailBounce.tsx | 2 +- .../GlobalNotifications.tsx | 4 +- .../manager/src/features/Images/ImageRow.tsx | 2 +- .../src/features/Images/ImageUpload.tsx | 2 +- .../Images/ImagesCreate/CreateImageTab.tsx | 2 +- packages/manager/src/features/Images/utils.ts | 2 +- .../KubeCheckoutBar/KubeCheckoutBar.tsx | 2 +- .../Linodes/LinodeEntityDetailBody.tsx | 2 +- .../Linodes/LinodeEntityDetailFooter.tsx | 2 +- .../LinodesCreate/SelectBackupPanel.tsx | 2 +- .../LinodeBackup/LinodeBackups.tsx | 2 +- .../LinodeBackup/ScheduleSettings.tsx | 2 +- .../LinodeConfigs/LinodeConfigs.tsx | 2 +- .../LinodeFirewallsActionMenu.tsx | 2 +- .../TransferHistory.tsx | 2 +- .../LinodeRebuild/LinodeRebuildDialog.tsx | 2 +- .../LinodeRebuild/RebuildFromImage.tsx | 2 +- .../LinodeRebuild/RebuildFromStackScript.tsx | 2 +- .../LinodeRescue/StandardRescueDialog.tsx | 2 +- .../LinodeResize/LinodeResize.tsx | 2 +- .../LinodeSettings/ImageAndPassword.tsx | 2 +- .../LinodeSettings/LinodeSettings.tsx | 2 +- .../LinodeStorage/LinodeDisks.tsx | 2 +- .../LinodeSummary/LinodeSummary.tsx | 2 +- .../MigrationNotification.tsx | 2 +- .../Linodes/LinodesLanding/CardView.tsx | 2 +- .../Linodes/MigrateLinode/MigrateLinode.tsx | 2 +- .../LongviewDetail/LongviewDetail.tsx | 2 +- .../LongviewLanding/LongviewClientHeader.tsx | 2 +- .../LongviewLanding/LongviewClientRow.tsx | 2 +- .../LongviewLanding/LongviewClients.tsx | 2 +- .../LongviewLanding/LongviewPlans.tsx | 2 +- .../Longview/shared/TimeRangeSelect.tsx | 5 +- .../ManagedChartPanel.tsx | 2 +- .../Managed/Monitors/IssueCalendar.tsx | 2 +- .../NodeBalancers/NodeBalancerCreate.tsx | 2 +- .../NodeBalancerDetail/NodeBalancerDetail.tsx | 2 +- .../NodeBalancerFirewallsActionMenu.tsx | 2 +- .../NodeBalancerSummary/TablesPanel.tsx | 2 +- .../useFormattedNotifications.tsx | 2 +- .../BucketDetail/ObjectDetailsDrawer.tsx | 2 +- .../BucketLanding/BucketDetailsDrawer.tsx | 2 +- .../BucketLanding/BucketLanding.tsx | 2 +- .../BucketLanding/CreateBucketDrawer.tsx | 2 +- .../BucketLanding/OMC_BucketLanding.tsx | 2 +- .../BucketLanding/OMC_CreateBucketDrawer.tsx | 2 +- .../Profile/APITokens/APITokenTable.tsx | 4 +- .../APITokens/CreateAPITokenDrawer.test.tsx | 12 +-- .../APITokens/CreateAPITokenDrawer.tsx | 4 +- .../Profile/APITokens/EditAPITokenDrawer.tsx | 2 +- .../Profile/APITokens/RevokeTokenDialog.tsx | 2 +- .../APITokens/ViewAPITokenDrawer.test.tsx | 4 +- .../Profile/APITokens/ViewAPITokenDrawer.tsx | 2 +- .../AuthenticationSettings.tsx | 2 +- .../PhoneVerification/PhoneVerification.tsx | 2 +- .../RevokeTrustedDevicesDialog.tsx | 2 +- .../AuthenticationSettings/SMSMessaging.tsx | 4 +- .../SecurityQuestions/SecurityQuestions.tsx | 2 +- .../AuthenticationSettings/TrustedDevices.tsx | 2 +- .../TwoFactor/DisableTwoFactorDialog.tsx | 2 +- .../TwoFactor/TwoFactor.tsx | 4 +- .../DisplaySettings/DisplaySettings.test.tsx | 4 +- .../DisplaySettings/DisplaySettings.tsx | 2 +- .../Profile/DisplaySettings/TimezoneForm.tsx | 2 +- .../Profile/LishSettings/LishSettings.tsx | 2 +- .../features/Profile/Referrals/Referrals.tsx | 2 +- .../Profile/SSHKeys/CreateSSHKeyDrawer.tsx | 2 +- .../Profile/SSHKeys/DeleteSSHKeyDialog.tsx | 2 +- .../Profile/SSHKeys/EditSSHKeyDrawer.tsx | 2 +- .../src/features/Profile/SSHKeys/SSHKeys.tsx | 2 +- .../Profile/Settings/PreferenceEditor.tsx | 5 +- .../features/Profile/Settings/Settings.tsx | 7 +- .../SelectStackScriptsSection.tsx | 2 +- .../StackScriptCreate/StackScriptCreate.tsx | 2 +- .../StackScriptActionMenu.tsx | 2 +- .../StackScriptPanel/StackScriptPanel.tsx | 2 +- .../StackScriptPanel/StackScriptsSection.tsx | 2 +- .../StackScripts/StackScriptsDetail.tsx | 2 +- .../SupportTicketDetail.tsx | 2 +- .../SupportTicketDetail/TicketStatus.tsx | 2 +- .../features/TopMenu/UserMenu/UserMenu.tsx | 2 +- .../manager/src/features/Users/UserDetail.tsx | 2 +- .../src/features/Users/UserProfile.tsx | 2 +- .../manager/src/features/Users/UserRow.tsx | 2 +- .../src/features/Users/UsersActionMenu.tsx | 2 +- .../src/features/Users/UsersLanding.tsx | 2 +- .../VPCDetail/SubnetAssignLinodesDrawer.tsx | 2 +- .../VPCs/VPCDetail/SubnetCreateDrawer.tsx | 2 +- .../VPCs/VPCDetail/SubnetEditDrawer.tsx | 2 +- .../VPCDetail/SubnetUnassignLinodesDrawer.tsx | 2 +- .../VPCs/VPCLanding/VPCEditDrawer.tsx | 2 +- .../features/Volumes/AttachVolumeDrawer.tsx | 2 +- .../features/Volumes/CloneVolumeDrawer.tsx | 2 +- .../src/features/Volumes/EditVolumeDrawer.tsx | 2 +- .../features/Volumes/ResizeVolumeDrawer.tsx | 2 +- .../src/features/Volumes/VolumeCreate.tsx | 2 +- .../VolumeDrawer/LinodeVolumeAttachForm.tsx | 2 +- .../manager/src/hooks/useAccountManagement.ts | 2 +- packages/manager/src/hooks/useCreateVPC.ts | 2 +- .../src/hooks/useDismissibleNotifications.ts | 5 +- .../manager/src/hooks/useEventHandlers.ts | 4 +- .../src/hooks/useGlobalKeyboardListener.ts | 5 +- .../manager/src/hooks/useInitialRequests.ts | 2 +- .../src/hooks/useIsResourceRestricted.ts | 2 +- packages/manager/src/hooks/useOrder.test.tsx | 2 +- packages/manager/src/hooks/useOrder.ts | 5 +- packages/manager/src/hooks/usePagination.ts | 5 +- .../useRestrictedGlobalGrantCheck.test.ts | 4 +- .../hooks/useRestrictedGlobalGrantCheck.ts | 2 +- .../manager/src/queries/account/account.ts | 2 +- .../manager/src/queries/account/agreements.ts | 2 +- .../manager/src/queries/account/payment.ts | 2 +- .../manager/src/queries/account/settings.ts | 2 +- packages/manager/src/queries/account/users.ts | 2 +- .../src/queries/databases/databases.ts | 2 +- packages/manager/src/queries/domains.ts | 2 +- .../manager/src/queries/entityTransfers.ts | 2 +- packages/manager/src/queries/firewalls.ts | 2 +- packages/manager/src/queries/images.ts | 2 +- packages/manager/src/queries/kubernetes.ts | 2 +- .../manager/src/queries/linodes/linodes.ts | 2 +- packages/manager/src/queries/nodebalancers.ts | 2 +- .../manager/src/queries/placementGroups.ts | 2 +- .../src/queries/{ => profile}/preferences.ts | 29 +++---- .../src/queries/{ => profile}/profile.ts | 40 +++++---- .../src/queries/profile/securityQuestions.ts | 79 +++++++++++++++++ .../src/queries/{ => profile}/tokens.ts | 0 .../manager/src/queries/securityQuestions.ts | 87 ------------------- .../manager/src/queries/volumes/volumes.ts | 2 +- packages/manager/src/useSetupFeatureFlags.ts | 2 +- packages/manager/src/utilities/theme.ts | 2 +- 176 files changed, 347 insertions(+), 312 deletions(-) create mode 100644 packages/manager/.changeset/pr-10543-tech-stories-1717514648816.md rename packages/manager/src/queries/{ => profile}/preferences.ts (66%) rename packages/manager/src/queries/{ => profile}/profile.ts (94%) create mode 100644 packages/manager/src/queries/profile/securityQuestions.ts rename packages/manager/src/queries/{ => profile}/tokens.ts (100%) delete mode 100644 packages/manager/src/queries/securityQuestions.ts diff --git a/packages/manager/.changeset/pr-10543-tech-stories-1717514648816.md b/packages/manager/.changeset/pr-10543-tech-stories-1717514648816.md new file mode 100644 index 00000000000..63ab16b1ed6 --- /dev/null +++ b/packages/manager/.changeset/pr-10543-tech-stories-1717514648816.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Query Key Factory for Security Questions and Preferences ([#10543](https://github.com/linode/manager/pull/10543)) diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index e60ef779999..c74fd368483 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -21,7 +21,10 @@ import { } from 'src/features/NotificationCenter/NotificationContext'; import { TopMenu } from 'src/features/TopMenu/TopMenu'; import { useFlags } from 'src/hooks/useFlags'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; import { ENABLE_MAINTENANCE_MODE } from './constants'; import { complianceUpdateContext } from './context/complianceUpdateContext'; @@ -32,7 +35,7 @@ import { useIsACLBEnabled } from './features/LoadBalancers/utils'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; import { useGlobalErrors } from './hooks/useGlobalErrors'; import { useAccountSettings } from './queries/account/settings'; -import { useProfile } from './queries/profile'; +import { useProfile } from './queries/profile/profile'; const useStyles = makeStyles()((theme: Theme) => ({ activationWrapper: { diff --git a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx index ac3d3805786..c3c8d41ebf4 100644 --- a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx +++ b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx @@ -15,7 +15,7 @@ import { Typography } from 'src/components/Typography'; import { CreateSSHKeyDrawer } from 'src/features/Profile/SSHKeys/CreateSSHKeyDrawer'; import { usePagination } from 'src/hooks/usePagination'; import { useAccountUsers } from 'src/queries/account/users'; -import { useProfile, useSSHKeysQuery } from 'src/queries/profile'; +import { useProfile, useSSHKeysQuery } from 'src/queries/profile/profile'; import { truncateAndJoinList } from 'src/utilities/stringUtils'; import { GravatarByEmail } from '../GravatarByEmail'; diff --git a/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx b/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx index d40e6deabb7..33d66afc056 100644 --- a/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx +++ b/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Typography } from 'src/components/Typography'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { TimeInterval, formatDate } from 'src/utilities/formatDate'; export interface DateTimeDisplayProps { diff --git a/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx b/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx index 3fa23cb336d..b33df97103d 100644 --- a/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx +++ b/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx @@ -7,7 +7,7 @@ import { Notice } from 'src/components/Notice/Notice'; import { TypeToConfirm } from 'src/components/TypeToConfirm/TypeToConfirm'; import { Typography } from 'src/components/Typography'; import { titlecase } from 'src/features/Linodes/presentation'; -import { usePreferences } from 'src/queries/preferences'; +import { usePreferences } from 'src/queries/profile/preferences'; import { capitalize } from 'src/utilities/capitalize'; import { DialogProps } from '../Dialog/Dialog'; diff --git a/packages/manager/src/components/MainContentBanner.tsx b/packages/manager/src/components/MainContentBanner.tsx index e639ce257b6..1746e4107fd 100644 --- a/packages/manager/src/components/MainContentBanner.tsx +++ b/packages/manager/src/components/MainContentBanner.tsx @@ -5,7 +5,10 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; import { Typography } from 'src/components/Typography'; import { useFlags } from 'src/hooks/useFlags'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; import { Box } from './Box'; diff --git a/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx b/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx index 19668bd69bf..c01acbaf9ba 100644 --- a/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx +++ b/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx @@ -5,7 +5,7 @@ import { Link } from 'react-router-dom'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; import { useAllAccountMaintenanceQuery } from 'src/queries/account/maintenance'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { formatDate } from 'src/utilities/formatDate'; import { isPast } from 'src/utilities/isPast'; diff --git a/packages/manager/src/components/OrderBy.tsx b/packages/manager/src/components/OrderBy.tsx index a4afc094d55..f1fca35a051 100644 --- a/packages/manager/src/components/OrderBy.tsx +++ b/packages/manager/src/components/OrderBy.tsx @@ -5,7 +5,10 @@ import { useHistory, useLocation } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; import { usePrevious } from 'src/hooks/usePrevious'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; import { ManagerPreferences } from 'src/types/ManagerPreferences'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; import { diff --git a/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx b/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx index b279ec36f83..b4494ec9a26 100644 --- a/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx +++ b/packages/manager/src/components/PreferenceToggle/PreferenceToggle.tsx @@ -1,4 +1,7 @@ -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; export interface PreferenceToggleProps { preference: T; diff --git a/packages/manager/src/components/TagCell/AddTag.tsx b/packages/manager/src/components/TagCell/AddTag.tsx index a03008278e2..94ded840506 100644 --- a/packages/manager/src/components/TagCell/AddTag.tsx +++ b/packages/manager/src/components/TagCell/AddTag.tsx @@ -1,7 +1,7 @@ import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { updateTagsSuggestionsData, useTagSuggestions } from 'src/queries/tags'; import { Autocomplete } from '../Autocomplete/Autocomplete'; diff --git a/packages/manager/src/components/TagsInput/TagsInput.tsx b/packages/manager/src/components/TagsInput/TagsInput.tsx index e58aa7c6951..0d82a178a4e 100644 --- a/packages/manager/src/components/TagsInput/TagsInput.tsx +++ b/packages/manager/src/components/TagsInput/TagsInput.tsx @@ -7,7 +7,7 @@ import Select, { Item, NoOptionsMessageProps, } from 'src/components/EnhancedSelect/Select'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { updateTagsSuggestionsData, useTagSuggestions } from 'src/queries/tags'; import { getErrorMap } from 'src/utilities/errorUtils'; diff --git a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx index 0a4c937fac3..55e555abe50 100644 --- a/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx +++ b/packages/manager/src/components/TypeToConfirmDialog/TypeToConfirmDialog.tsx @@ -10,7 +10,7 @@ import { TypeToConfirm, TypeToConfirmProps, } from 'src/components/TypeToConfirm/TypeToConfirm'; -import { usePreferences } from 'src/queries/preferences'; +import { usePreferences } from 'src/queries/profile/preferences'; interface EntityInfo { action?: diff --git a/packages/manager/src/containers/preferences.container.ts b/packages/manager/src/containers/preferences.container.ts index dbc04852d30..48736a01f65 100644 --- a/packages/manager/src/containers/preferences.container.ts +++ b/packages/manager/src/containers/preferences.container.ts @@ -1,6 +1,9 @@ import React from 'react'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; import { ManagerPreferences } from 'src/types/ManagerPreferences'; export interface PreferencesStateProps { preferences?: ManagerPreferences; diff --git a/packages/manager/src/containers/profile.container.ts b/packages/manager/src/containers/profile.container.ts index 0e8a0d986cb..0ba0c0d0b29 100644 --- a/packages/manager/src/containers/profile.container.ts +++ b/packages/manager/src/containers/profile.container.ts @@ -3,7 +3,7 @@ import { APIError } from '@linode/api-v4/lib/types'; import * as React from 'react'; import { UseQueryResult } from '@tanstack/react-query'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; export interface WithProfileProps { grants: UseQueryResult; diff --git a/packages/manager/src/features/Account/AccountLanding.tsx b/packages/manager/src/features/Account/AccountLanding.tsx index 165b7665143..df7e7fc20b4 100644 --- a/packages/manager/src/features/Account/AccountLanding.tsx +++ b/packages/manager/src/features/Account/AccountLanding.tsx @@ -16,7 +16,7 @@ import { useIsParentTokenExpired } from 'src/features/Account/SwitchAccounts/use import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useAccount } from 'src/queries/account/account'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics'; import AccountLogins from './AccountLogins'; diff --git a/packages/manager/src/features/Account/AccountLogins.tsx b/packages/manager/src/features/Account/AccountLogins.tsx index bbcf658fa40..055a28e1a32 100644 --- a/packages/manager/src/features/Account/AccountLogins.tsx +++ b/packages/manager/src/features/Account/AccountLogins.tsx @@ -19,7 +19,7 @@ import { Typography } from 'src/components/Typography'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useAccountLoginsQuery } from 'src/queries/account/logins'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import AccountLoginsTableRow from './AccountLoginsTableRow'; import { getRestrictedResourceText } from './utils'; diff --git a/packages/manager/src/features/Account/AccountLoginsTableRow.tsx b/packages/manager/src/features/Account/AccountLoginsTableRow.tsx index 5763a6c8227..1fa71109e86 100644 --- a/packages/manager/src/features/Account/AccountLoginsTableRow.tsx +++ b/packages/manager/src/features/Account/AccountLoginsTableRow.tsx @@ -9,7 +9,7 @@ import { Link } from 'src/components/Link'; import { Status, StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { capitalize } from 'src/utilities/capitalize'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/features/Account/CloseAccountDialog.tsx b/packages/manager/src/features/Account/CloseAccountDialog.tsx index e4d3cf748ae..9513c701954 100644 --- a/packages/manager/src/features/Account/CloseAccountDialog.tsx +++ b/packages/manager/src/features/Account/CloseAccountDialog.tsx @@ -9,7 +9,7 @@ import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { Typography } from 'src/components/Typography'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; interface Props { closeDialog: () => void; diff --git a/packages/manager/src/features/Account/CloseAccountSetting.test.tsx b/packages/manager/src/features/Account/CloseAccountSetting.test.tsx index 9117221c428..03555a3e0da 100644 --- a/packages/manager/src/features/Account/CloseAccountSetting.test.tsx +++ b/packages/manager/src/features/Account/CloseAccountSetting.test.tsx @@ -16,8 +16,8 @@ const queryMocks = vi.hoisted(() => ({ useProfile: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/profile', async () => { - const actual = await vi.importActual('src/queries/profile'); +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); return { ...actual, useProfile: queryMocks.useProfile, diff --git a/packages/manager/src/features/Account/CloseAccountSetting.tsx b/packages/manager/src/features/Account/CloseAccountSetting.tsx index 23e260969bb..8ef0230b21e 100644 --- a/packages/manager/src/features/Account/CloseAccountSetting.tsx +++ b/packages/manager/src/features/Account/CloseAccountSetting.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { Accordion } from 'src/components/Accordion'; import { Button } from 'src/components/Button/Button'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import CloseAccountDialog from './CloseAccountDialog'; import { diff --git a/packages/manager/src/features/Account/EnableObjectStorage.tsx b/packages/manager/src/features/Account/EnableObjectStorage.tsx index f6562865e36..66f4b2ca586 100644 --- a/packages/manager/src/features/Account/EnableObjectStorage.tsx +++ b/packages/manager/src/features/Account/EnableObjectStorage.tsx @@ -13,7 +13,7 @@ import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToCo import { Typography } from 'src/components/Typography'; import { updateAccountSettingsData } from 'src/queries/account/settings'; import { queryKey } from 'src/queries/objectStorage'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; interface Props { object_storage: AccountSettings['object_storage']; } diff --git a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx index 8848c6ad16a..6b40b3ced15 100644 --- a/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx +++ b/packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx @@ -8,7 +8,7 @@ import { Status, StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { Tooltip } from 'src/components/Tooltip'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { capitalize } from 'src/utilities/capitalize'; import { parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/features/Backups/BackupsCTA.tsx b/packages/manager/src/features/Backups/BackupsCTA.tsx index 62a833ff4fb..6920e171d3e 100644 --- a/packages/manager/src/features/Backups/BackupsCTA.tsx +++ b/packages/manager/src/features/Backups/BackupsCTA.tsx @@ -6,8 +6,11 @@ import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; import { Typography } from 'src/components/Typography'; import { useAccountSettings } from 'src/queries/account/settings'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; -import { useProfile } from 'src/queries/profile'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; +import { useProfile } from 'src/queries/profile/profile'; import { BackupDrawer } from './BackupDrawer'; import { StyledPaper } from './BackupsCTA.styles'; diff --git a/packages/manager/src/features/Billing/BillingDetail.tsx b/packages/manager/src/features/Billing/BillingDetail.tsx index dd2e7dd4807..6102f09b7c8 100644 --- a/packages/manager/src/features/Billing/BillingDetail.tsx +++ b/packages/manager/src/features/Billing/BillingDetail.tsx @@ -11,7 +11,7 @@ import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { PAYPAL_CLIENT_ID } from 'src/constants'; import { useAccount } from 'src/queries/account/account'; import { useAllPaymentMethodsQuery } from 'src/queries/account/payment'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import BillingActivityPanel from './BillingPanels/BillingActivityPanel/BillingActivityPanel'; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx index 8cbb72aa281..c0891c7d83d 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx @@ -39,7 +39,7 @@ import { useAllAccountInvoices, useAllAccountPayments, } from 'src/queries/account/billing'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx index b18adbaa7d2..b2ceee1d0cc 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/BillingSummary.tsx @@ -14,7 +14,7 @@ import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useNotificationsQuery } from 'src/queries/account/notifications'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { isWithinDays } from 'src/utilities/date'; import { BillingPaper } from '../../BillingDetail'; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx index 56ce8a853e5..9ee3fec3f06 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx @@ -23,7 +23,7 @@ import { Typography } from 'src/components/Typography'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useAccount } from 'src/queries/account/account'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { isCreditCardExpired } from 'src/utilities/creditCard'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx index 59a2e8b65ec..f536dda3c10 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.test.tsx @@ -29,8 +29,8 @@ const props = { zip: '19106', }; -vi.mock('src/queries/profile', async () => { - const actual = await vi.importActual('src/queries/profile'); +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); return { ...actual, useGrants: queryMocks.useGrants, diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx index 7fb93414ed6..3c2fe20117d 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx @@ -12,7 +12,7 @@ import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useAccount, useMutateAccount } from 'src/queries/account/account'; import { useNotificationsQuery } from 'src/queries/account/notifications'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getErrorMap } from 'src/utilities/errorUtils'; interface Props { diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/AddPaymentMethodDrawer/AddPaymentMethodDrawer.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/AddPaymentMethodDrawer/AddPaymentMethodDrawer.tsx index ee1d2bbed88..de74330f0df 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/AddPaymentMethodDrawer/AddPaymentMethodDrawer.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/AddPaymentMethodDrawer/AddPaymentMethodDrawer.tsx @@ -13,7 +13,7 @@ import { Typography } from 'src/components/Typography'; import { MAXIMUM_PAYMENT_METHODS } from 'src/constants'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import GooglePayChip from '../GooglePayChip'; import { PayPalChip } from '../PayPalChip'; diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx index 69ab5d5627d..44253932d73 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.test.tsx @@ -25,8 +25,8 @@ const queryMocks = vi.hoisted(() => ({ useProfile: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/profile', async () => { - const actual = await vi.importActual('src/queries/profile'); +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); return { ...actual, useGrants: queryMocks.useGrants, diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/RestoreFromBackupDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/RestoreFromBackupDialog.tsx index 96f69df113b..f4ea747f158 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/RestoreFromBackupDialog.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/RestoreFromBackupDialog.tsx @@ -8,7 +8,7 @@ import { Notice } from 'src/components/Notice/Notice'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { Typography } from 'src/components/Typography'; import { useRestoreFromBackupMutation } from 'src/queries/databases/databases'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx index 5f7674501e9..70594ad618c 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { Divider } from 'src/components/Divider'; import { Typography } from 'src/components/Typography'; import { Paper } from 'src/components/Paper'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import AccessControls from '../AccessControls'; import DatabaseSettingsDeleteClusterDialog from './DatabaseSettingsDeleteClusterDialog'; diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx index 7ce00cb3ed6..5646cb54753 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx @@ -11,7 +11,7 @@ import { Chip } from 'src/components/Chip'; import { Hidden } from 'src/components/Hidden'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { isWithinDays, parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/features/Domains/CloneDomainDrawer.tsx b/packages/manager/src/features/Domains/CloneDomainDrawer.tsx index dc5158b9fec..ff6345ffe27 100644 --- a/packages/manager/src/features/Domains/CloneDomainDrawer.tsx +++ b/packages/manager/src/features/Domains/CloneDomainDrawer.tsx @@ -11,7 +11,7 @@ import { TextField } from 'src/components/TextField'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { RadioGroup } from 'src/components/RadioGroup'; import { useCloneDomainMutation } from 'src/queries/domains'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; interface CloneDomainDrawerProps { domain: Domain | undefined; diff --git a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx index b96d33aaefa..a92bb23da93 100644 --- a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx +++ b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx @@ -30,7 +30,7 @@ import { reportException } from 'src/exceptionReporting'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { NodeBalancerSelect } from 'src/features/NodeBalancers/NodeBalancerSelect'; import { useCreateDomainMutation } from 'src/queries/domains'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { sendCreateDomainEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getErrorMap } from 'src/utilities/errorUtils'; import { diff --git a/packages/manager/src/features/Domains/DomainZoneImportDrawer.tsx b/packages/manager/src/features/Domains/DomainZoneImportDrawer.tsx index fa574a15065..0a59f0428a7 100644 --- a/packages/manager/src/features/Domains/DomainZoneImportDrawer.tsx +++ b/packages/manager/src/features/Domains/DomainZoneImportDrawer.tsx @@ -8,7 +8,7 @@ import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; import { useImportZoneMutation } from 'src/queries/domains'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getErrorMap } from 'src/utilities/errorUtils'; interface DomainZoneImportDrawerProps { diff --git a/packages/manager/src/features/Domains/DomainsLanding.tsx b/packages/manager/src/features/Domains/DomainsLanding.tsx index c93915fb91f..d50380ec715 100644 --- a/packages/manager/src/features/Domains/DomainsLanding.tsx +++ b/packages/manager/src/features/Domains/DomainsLanding.tsx @@ -27,7 +27,7 @@ import { useUpdateDomainMutation, } from 'src/queries/domains'; import { useLinodesQuery } from 'src/queries/linodes/linodes'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { CloneDomainDrawer } from './CloneDomainDrawer'; diff --git a/packages/manager/src/features/Domains/EditDomainDrawer.tsx b/packages/manager/src/features/Domains/EditDomainDrawer.tsx index bec7a26e1c7..14c2c3c0c31 100644 --- a/packages/manager/src/features/Domains/EditDomainDrawer.tsx +++ b/packages/manager/src/features/Domains/EditDomainDrawer.tsx @@ -12,7 +12,7 @@ import { RadioGroup } from 'src/components/RadioGroup'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; import { useUpdateDomainMutation } from 'src/queries/domains'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getErrorMap } from 'src/utilities/errorUtils'; import { handleFormikBlur } from 'src/utilities/formikTrimUtil'; import { diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx index 3b935f3511e..27f7057130d 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx @@ -17,7 +17,7 @@ import { queryKey, useTransferQuery, } from 'src/queries/entityTransfers'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { sendEntityTransferReceiveEvent } from 'src/utilities/analytics/customEventAnalytics'; import { capitalize } from 'src/utilities/capitalize'; import { parseAPIDate } from 'src/utilities/date'; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx index 1ae36cb4743..5efc15e910b 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx @@ -14,7 +14,7 @@ import { useAddFirewallDeviceMutation, useAllFirewallsQuery, } from 'src/queries/firewalls'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getEntityIdsByPermission } from 'src/utilities/grants'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx index c867c4c1f67..b2698e00c3a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx @@ -17,7 +17,7 @@ import { useAllFirewallsQuery, } from 'src/queries/firewalls'; import { queryKey } from 'src/queries/nodebalancers'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getEntityIdsByPermission } from 'src/utilities/grants'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx index 3b44b84ab13..f0e58906668 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx @@ -12,7 +12,7 @@ import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; import { useFirewallQuery, useMutateFirewall } from 'src/queries/firewalls'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { checkIfUserCanModifyFirewall } from '../shared'; diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx index 24b0e3efdb5..08be0d94aae 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx @@ -34,7 +34,7 @@ import { } from 'src/queries/firewalls'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; import { queryKey as nodebalancersQueryKey } from 'src/queries/nodebalancers'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { sendLinodeCreateFormStepEvent } from 'src/utilities/analytics/formEventAnalytics'; import { getErrorMap } from 'src/utilities/errorUtils'; import { diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallActionMenu.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallActionMenu.tsx index 26611f72110..1d97f27a09e 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallActionMenu.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallActionMenu.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { checkIfUserCanModifyFirewall } from '../shared'; diff --git a/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx index 48167eb63ce..65d4a952805 100644 --- a/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx +++ b/packages/manager/src/features/GlobalNotifications/EmailBounce.tsx @@ -10,7 +10,7 @@ import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; import { useAccount, useMutateAccount } from 'src/queries/account/account'; import { useNotificationsQuery } from 'src/queries/account/notifications'; -import { useMutateProfile, useProfile } from 'src/queries/profile'; +import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; import { StyledGrid } from './EmailBounce.styles'; diff --git a/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx b/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx index 69613c55293..756ad8b912e 100644 --- a/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx +++ b/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx @@ -7,8 +7,8 @@ import { switchAccountSessionContext } from 'src/context/switchAccountSessionCon import { SwitchAccountSessionDialog } from 'src/features/Account/SwitchAccounts/SwitchAccountSessionDialog'; import { useDismissibleNotifications } from 'src/hooks/useDismissibleNotifications'; import { useFlags } from 'src/hooks/useFlags'; -import { useProfile } from 'src/queries/profile'; -import { useSecurityQuestions } from 'src/queries/securityQuestions'; +import { useProfile } from 'src/queries/profile/profile'; +import { useSecurityQuestions } from 'src/queries/profile/securityQuestions'; import { SessionExpirationDialog } from '../Account/SwitchAccounts/SessionExpirationDialog'; import { APIMaintenanceBanner } from './APIMaintenanceBanner'; diff --git a/packages/manager/src/features/Images/ImageRow.tsx b/packages/manager/src/features/Images/ImageRow.tsx index e3eee26d2fc..f4cec9bf9f1 100644 --- a/packages/manager/src/features/Images/ImageRow.tsx +++ b/packages/manager/src/features/Images/ImageRow.tsx @@ -6,7 +6,7 @@ import { Hidden } from 'src/components/Hidden'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { capitalizeAllWords } from 'src/utilities/capitalize'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/features/Images/ImageUpload.tsx b/packages/manager/src/features/Images/ImageUpload.tsx index 704a0bce61a..16f1a318504 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImageUpload.tsx @@ -32,7 +32,7 @@ import { useMutateAccountAgreements, } from 'src/queries/account/agreements'; import { useUploadImageMutation } from 'src/queries/images'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { setPendingUpload } from 'src/store/pendingUpload'; import { getGDPRDetails } from 'src/utilities/formatRegion'; diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index 9bd56e55c8b..d32eca891ae 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -29,7 +29,7 @@ import { useEventsPollingActions } from 'src/queries/events/events'; import { useCreateImageMutation } from 'src/queries/images'; import { useAllLinodeDisksQuery } from 'src/queries/linodes/disks'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; export const CreateImageTab = () => { diff --git a/packages/manager/src/features/Images/utils.ts b/packages/manager/src/features/Images/utils.ts index cb7b2efed85..193eda19887 100644 --- a/packages/manager/src/features/Images/utils.ts +++ b/packages/manager/src/features/Images/utils.ts @@ -1,4 +1,4 @@ -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import type { Image, Linode } from '@linode/api-v4'; diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx index 874229f9a5a..16d3f07758e 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx @@ -11,7 +11,7 @@ import { Notice } from 'src/components/Notice/Notice'; import { RenderGuard } from 'src/components/RenderGuard'; import { EUAgreementCheckbox } from 'src/features/Account/Agreements/EUAgreementCheckbox'; import { useAccountAgreements } from 'src/queries/account/agreements'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useSpecificTypes } from 'src/queries/types'; import { extendTypesQueryResult } from 'src/utilities/extendType'; import { getGDPRDetails } from 'src/utilities/formatRegion'; diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx index 8e19675c618..6569ab3ce14 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailBody.tsx @@ -14,7 +14,7 @@ import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption import { Link } from 'src/components/Link'; import { Typography, TypographyProps } from 'src/components/Typography'; import { AccessTable } from 'src/features/Linodes/AccessTable'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { pluralize } from 'src/utilities/pluralize'; import { encryptionStatusTestId } from '../Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodeTable'; diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx index 02ca88438f7..30d8a7313f2 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { TagCell } from 'src/components/TagCell/TagCell'; import { useLinodeUpdateMutation } from 'src/queries/linodes/linodes'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectBackupPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectBackupPanel.tsx index fafb7e11394..e95328235d5 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectBackupPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectBackupPanel.tsx @@ -12,7 +12,7 @@ import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; import { Typography } from 'src/components/Typography'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { formatDate } from 'src/utilities/formatDate'; export const aggregateBackups = ( diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx index 5e445f5efdc..0ed0f699554 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/LinodeBackups.tsx @@ -18,7 +18,7 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { Typography } from 'src/components/Typography'; import { useLinodeBackupsQuery } from 'src/queries/linodes/backups'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useTypeQuery } from 'src/queries/types'; import { getMonthlyBackupsPrice } from 'src/utilities/pricing/backups'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.tsx index 46c447e94e1..ca78e553f1a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.tsx @@ -14,7 +14,7 @@ import { useLinodeQuery, useLinodeUpdateMutation, } from 'src/queries/linodes/linodes'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getUserTimezone } from 'src/utilities/getUserTimezone'; import { initWindows } from 'src/utilities/initWindows'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx index 603b6bc411c..1b1f25cbe3a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/LinodeConfigs.tsx @@ -16,7 +16,7 @@ import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; import { useAllLinodeConfigsQuery } from 'src/queries/linodes/configs'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { sendLinodeConfigurationDocsEvent } from 'src/utilities/analytics/customEventAnalytics'; import { BootConfigDialog } from './BootConfigDialog'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsActionMenu.tsx index 04e0c74e5cb..35b15fc5219 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsActionMenu.tsx @@ -4,7 +4,7 @@ import { Action } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { noPermissionTooltipText } from 'src/features/Firewalls/FirewallLanding/FirewallActionMenu'; import { checkIfUserCanModifyFirewall } from 'src/features/Firewalls/shared'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; interface LinodeFirewallsActionMenuProps { firewallID: number; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx index 3fbbb76e73e..c79628fb224 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx @@ -23,7 +23,7 @@ import { useLinodeStatsByDate, useLinodeTransferByDate, } from 'src/queries/linodes/stats'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { readableBytes } from 'src/utilities/unitConversions'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx index 1a85ffba943..715c8a7b878 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx @@ -6,7 +6,7 @@ import EnhancedSelect, { Item } from 'src/components/EnhancedSelect/Select'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { HostMaintenanceError } from '../HostMaintenanceError'; import { LinodePermissionsError } from '../LinodePermissionsError'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx index 9ac76f6f83d..e0155d4adc5 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx @@ -21,7 +21,7 @@ import { regionSupportsMetadata } from 'src/features/Linodes/LinodesCreate/utili import { useFlags } from 'src/hooks/useFlags'; import { useEventsPollingActions } from 'src/queries/events/events'; import { useAllImagesQuery } from 'src/queries/images'; -import { usePreferences } from 'src/queries/preferences'; +import { usePreferences } from 'src/queries/profile/preferences'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx index a557df4d25a..fe6641fa1ce 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx @@ -25,7 +25,7 @@ import { useStackScript } from 'src/hooks/useStackScript'; import { listToItemsByID } from 'src/queries/base'; import { useEventsPollingActions } from 'src/queries/events/events'; import { useAllImagesQuery } from 'src/queries/images'; -import { usePreferences } from 'src/queries/preferences'; +import { usePreferences } from 'src/queries/profile/preferences'; import { filterImagesByType } from 'src/store/image/image.helpers'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx index cb09d9ca5f8..ec80ceab472 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx @@ -17,7 +17,7 @@ import { useLinodeQuery, useLinodeRescueMutation, } from 'src/queries/linodes/linodes'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { useAllVolumesQuery } from 'src/queries/volumes/volumes'; import { DevicesAsStrings, diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx index 3fde980bb26..6d47d62e872 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx @@ -26,7 +26,7 @@ import { useLinodeQuery, useLinodeResizeMutation, } from 'src/queries/linodes/linodes'; -import { usePreferences } from 'src/queries/preferences'; +import { usePreferences } from 'src/queries/profile/preferences'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useAllTypes } from 'src/queries/types'; import { extendType } from 'src/utilities/extendType'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx index cbf0a15c7b5..ccd8d36357b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/ImageAndPassword.tsx @@ -5,7 +5,7 @@ import { AccessPanel } from 'src/components/AccessPanel/AccessPanel'; import { Item } from 'src/components/EnhancedSelect/Select'; import { ImageSelect } from 'src/features/Images/ImageSelect'; import { useAllImagesQuery } from 'src/queries/images'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { LinodePermissionsError } from '../LinodePermissionsError'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettings.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettings.tsx index 892dec45064..fa5ea84dd97 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettings.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettings.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { useParams } from 'react-router-dom'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { LinodeSettingsAlertsPanel } from './LinodeSettingsAlertsPanel'; import { LinodeSettingsDeletePanel } from './LinodeSettingsDeletePanel'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx index 9bec03d9b25..5fda62cd70f 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/LinodeDisks.tsx @@ -20,7 +20,7 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { useAllLinodeDisksQuery } from 'src/queries/linodes/disks'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { sendEvent } from 'src/utilities/analytics/utils'; import { CreateDiskDrawer } from './CreateDiskDrawer'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx index 10b9bdbd9bf..2a38f1b9474 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx @@ -24,7 +24,7 @@ import { useLinodeStats, useLinodeStatsByDate, } from 'src/queries/linodes/stats'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { setUpCharts } from 'src/utilities/charts'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MigrationNotification.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MigrationNotification.tsx index af52fbcd5e8..c6ecfe0e849 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MigrationNotification.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MigrationNotification.tsx @@ -9,7 +9,7 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; import { useDialog } from 'src/hooks/useDialog'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { capitalize } from 'src/utilities/capitalize'; import { parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/features/Linodes/LinodesLanding/CardView.tsx b/packages/manager/src/features/Linodes/LinodesLanding/CardView.tsx index 10253f3994b..667158a0c04 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/CardView.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/CardView.tsx @@ -7,7 +7,7 @@ import { Typography } from 'src/components/Typography'; import { LinodeEntityDetail } from 'src/features/Linodes/LinodeEntityDetail'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useLinodeUpdateMutation } from 'src/queries/linodes/linodes'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { RenderLinodesProps } from './DisplayLinodes'; diff --git a/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx b/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx index 12d70ec0025..b9a2986f3ef 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx @@ -30,7 +30,7 @@ import { useLinodeMigrateMutation, useLinodeQuery, } from 'src/queries/linodes/linodes'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useTypeQuery } from 'src/queries/types'; import { sendMigrationInitiatedEvent } from 'src/utilities/analytics/customEventAnalytics'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx b/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx index 99799d154f5..165276620e6 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx @@ -27,7 +27,7 @@ import { LongviewTopProcesses, } from 'src/features/Longview/request.types'; import { useAPIRequest } from 'src/hooks/useAPIRequest'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useClientLastUpdated } from '../shared/useClientLastUpdated'; import { Apache } from './DetailTabs/Apache/Apache'; diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.tsx index 5ca99a981e0..5c43ea21b7b 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.tsx @@ -11,7 +11,7 @@ import { DispatchProps } from 'src/containers/longview.container'; import withClientStats, { Props as LVDataProps, } from 'src/containers/longview.stats.container'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { formatDate } from 'src/utilities/formatDate'; import { formatUptime } from 'src/utilities/formatUptime'; diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientRow.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientRow.tsx index 6af219628b4..c55a084bc15 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientRow.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientRow.tsx @@ -10,7 +10,7 @@ import withLongviewClients, { import withClientStats, { Props as LVDataProps, } from 'src/containers/longview.stats.container'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { useClientLastUpdated } from '../shared/useClientLastUpdated'; import { CPUGauge } from './Gauges/CPU'; diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx index ca4639b0c66..113752848f3 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx @@ -17,7 +17,7 @@ import withLongviewClients, { Props as LongviewProps, } from 'src/containers/longview.container'; import { useAccountSettings } from 'src/queries/account/settings'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { State as StatsState } from 'src/store/longviewStats/longviewStats.reducer'; import { MapState } from 'src/store/types'; diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewPlans.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewPlans.tsx index 6a1d777ae9b..2ae13c75b47 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewPlans.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewPlans.tsx @@ -22,7 +22,7 @@ import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { UseAPIRequest } from 'src/hooks/useAPIRequest'; import { useAccountSettings } from 'src/queries/account/settings'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { diff --git a/packages/manager/src/features/Longview/shared/TimeRangeSelect.tsx b/packages/manager/src/features/Longview/shared/TimeRangeSelect.tsx index 4a81055c25c..680b10a42a2 100644 --- a/packages/manager/src/features/Longview/shared/TimeRangeSelect.tsx +++ b/packages/manager/src/features/Longview/shared/TimeRangeSelect.tsx @@ -6,7 +6,10 @@ import Select, { BaseSelectProps, Item, } from 'src/components/EnhancedSelect/Select'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; interface Props extends Omit< diff --git a/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedChartPanel.tsx b/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedChartPanel.tsx index 62a8f7f1e86..83bb9e33cba 100644 --- a/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedChartPanel.tsx +++ b/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedChartPanel.tsx @@ -14,7 +14,7 @@ import { generateNetworkUnits, } from 'src/features/Longview/shared/utilities'; import { useManagedStatsQuery } from 'src/queries/managed/managed'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getUserTimezone } from 'src/utilities/getUserTimezone'; diff --git a/packages/manager/src/features/Managed/Monitors/IssueCalendar.tsx b/packages/manager/src/features/Managed/Monitors/IssueCalendar.tsx index 0ea39a8aed1..22cd98fd1a9 100644 --- a/packages/manager/src/features/Managed/Monitors/IssueCalendar.tsx +++ b/packages/manager/src/features/Managed/Monitors/IssueCalendar.tsx @@ -2,7 +2,7 @@ import { ManagedIssue } from '@linode/api-v4'; import { DateTime } from 'luxon'; import * as React from 'react'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { parseAPIDate } from 'src/utilities/date'; import { getUserTimezone } from 'src/utilities/getUserTimezone'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index f4c44a9f990..502d9f7e933 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -41,7 +41,7 @@ import { useNodeBalancerTypesQuery, useNodebalancerCreateMutation, } from 'src/queries/nodebalancers'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { sendCreateNodeBalancerEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx index 506e98e0c68..886043c757e 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx @@ -20,7 +20,7 @@ import { useNodeBalancerQuery, useNodebalancerUpdateMutation, } from 'src/queries/nodebalancers'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { getErrorMap } from 'src/utilities/errorUtils'; import NodeBalancerConfigurations from './NodeBalancerConfigurations'; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx index 9ecf185236c..74507f315f6 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx @@ -4,7 +4,7 @@ import { Action } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { noPermissionTooltipText } from 'src/features/Firewalls/FirewallLanding/FirewallActionMenu'; import { checkIfUserCanModifyFirewall } from 'src/features/Firewalls/shared'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; interface Props { firewallID: number; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx index b77813c7574..125386a85a0 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx @@ -20,7 +20,7 @@ import { useNodeBalancerQuery, useNodeBalancerStats, } from 'src/queries/nodebalancers'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getUserTimezone } from 'src/utilities/getUserTimezone'; import { formatNumber, getMetrics } from 'src/utilities/statMetrics'; diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx index 4e5f433385b..d67fdbb5807 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/useFormattedNotifications.tsx @@ -17,7 +17,7 @@ import { complianceUpdateContext } from 'src/context/complianceUpdateContext'; import { reportException } from 'src/exceptionReporting'; import { useDismissibleNotifications } from 'src/hooks/useDismissibleNotifications'; import { useNotificationsQuery } from 'src/queries/account/notifications'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx index 3301589c9f5..cd9f2f31979 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/ObjectDetailsDrawer.tsx @@ -11,7 +11,7 @@ import { Divider } from 'src/components/Divider'; import { Drawer } from 'src/components/Drawer'; import { Link } from 'src/components/Link'; import { Typography } from 'src/components/Typography'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { formatDate } from 'src/utilities/formatDate'; import { truncateMiddle } from 'src/utilities/truncate'; import { readableBytes } from 'src/utilities/unitConversions'; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx index 4bb73143f48..70fb01f55b8 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx @@ -15,7 +15,7 @@ import { Typography } from 'src/components/Typography'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; import { useFlags } from 'src/hooks/useFlags'; import { useObjectStorageClusters } from 'src/queries/objectStorage'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx index af8178d9819..f3e6c7b16f7 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketLanding.tsx @@ -26,7 +26,7 @@ import { useObjectStorageBuckets, useObjectStorageClusters, } from 'src/queries/objectStorage'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx index f470c3d3d0f..11c89b01580 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx @@ -21,7 +21,7 @@ import { useObjectStorageClusters, useObjectStorageTypesQuery, } from 'src/queries/objectStorage'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { sendCreateBucketEvent } from 'src/utilities/analytics/customEventAnalytics'; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx index 748f532c4ed..ab2f8f2a12e 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx @@ -23,7 +23,7 @@ import { useDeleteBucketWithRegionMutation, useObjectStorageBuckets, } from 'src/queries/objectStorage'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx index 5143f9bff11..1438e669c81 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx @@ -17,7 +17,7 @@ import { useCreateBucketMutation, useObjectStorageBuckets, } from 'src/queries/objectStorage'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { sendCreateBucketEvent } from 'src/utilities/analytics/customEventAnalytics'; diff --git a/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx b/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx index 66cce009e4e..84cb4fc7f28 100644 --- a/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx +++ b/packages/manager/src/features/Profile/APITokens/APITokenTable.tsx @@ -21,11 +21,11 @@ import { PROXY_USER_RESTRICTED_TOOLTIP_TEXT } from 'src/features/Account/constan import { SecretTokenDialog } from 'src/features/Profile/SecretTokenDialog/SecretTokenDialog'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useAppTokensQuery, usePersonalAccessTokensQuery, -} from 'src/queries/tokens'; +} from 'src/queries/profile/tokens'; import { APITokenMenu } from './APITokenMenu'; import { diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx index 7b23c2f67f7..38f9fa7c00b 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx @@ -16,22 +16,14 @@ const queryMocks = vi.hoisted(() => ({ useProfile: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/profile', async () => { - const actual = await vi.importActual('src/queries/profile'); +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); return { ...actual, useProfile: queryMocks.useProfile, }; }); -vi.mock('src/queries/grants', async () => { - const actual = await vi.importActual('src/queries/grants'); - return { - ...actual, - useGrants: queryMocks.useGrants, - }; -}); - const props = { onClose: vi.fn(), open: true, diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx index 71c93899c2c..8ce9ba24bd7 100644 --- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx @@ -18,8 +18,8 @@ import { ISO_DATETIME_NO_TZ_FORMAT } from 'src/constants'; import { AccessCell } from 'src/features/ObjectStorage/AccessKeyLanding/AccessCell'; import { VPC_READ_ONLY_TOOLTIP } from 'src/features/VPCs/constants'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import { useProfile } from 'src/queries/profile'; -import { useCreatePersonalAccessTokenMutation } from 'src/queries/tokens'; +import { useProfile } from 'src/queries/profile/profile'; +import { useCreatePersonalAccessTokenMutation } from 'src/queries/profile/tokens'; import { getErrorMap } from 'src/utilities/errorUtils'; import { diff --git a/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.tsx index 2d351114ce3..c64ae31140a 100644 --- a/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.tsx @@ -6,7 +6,7 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; -import { useUpdatePersonalAccessTokenMutation } from 'src/queries/tokens'; +import { useUpdatePersonalAccessTokenMutation } from 'src/queries/profile/tokens'; import { getErrorMap } from 'src/utilities/errorUtils'; interface Props { diff --git a/packages/manager/src/features/Profile/APITokens/RevokeTokenDialog.tsx b/packages/manager/src/features/Profile/APITokens/RevokeTokenDialog.tsx index 5f0371fcb8d..039f2781ec4 100644 --- a/packages/manager/src/features/Profile/APITokens/RevokeTokenDialog.tsx +++ b/packages/manager/src/features/Profile/APITokens/RevokeTokenDialog.tsx @@ -8,7 +8,7 @@ import { Typography } from 'src/components/Typography'; import { useRevokeAppAccessTokenMutation, useRevokePersonalAccessTokenMutation, -} from 'src/queries/tokens'; +} from 'src/queries/profile/tokens'; import { APITokenType } from './APITokenTable'; diff --git a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx index 2a5a4e7f6c7..7b1b44387e7 100644 --- a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx +++ b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.test.tsx @@ -14,8 +14,8 @@ const queryMocks = vi.hoisted(() => ({ useProfile: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/profile', async () => { - const actual = await vi.importActual('src/queries/profile'); +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); return { ...actual, useProfile: queryMocks.useProfile, diff --git a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx index 94339546e54..1d1744d43f2 100644 --- a/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/ViewAPITokenDrawer.tsx @@ -8,7 +8,7 @@ import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { AccessCell } from 'src/features/ObjectStorage/AccessKeyLanding/AccessCell'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { StyledAccessCell, diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/AuthenticationSettings.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/AuthenticationSettings.tsx index 36ffc36ddd4..c0c759fc0e4 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/AuthenticationSettings.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/AuthenticationSettings.tsx @@ -9,7 +9,7 @@ import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Link } from 'src/components/Link'; import { Paper } from 'src/components/Paper'; import { Typography } from 'src/components/Typography'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { PhoneVerification } from './PhoneVerification/PhoneVerification'; import { ResetPassword } from './ResetPassword'; diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx index 351dc9ba7a8..4d94c22a06f 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx @@ -17,7 +17,7 @@ import { useProfile, useSendPhoneVerificationCodeMutation, useVerifyPhoneVerificationCodeMutation, -} from 'src/queries/profile'; +} from 'src/queries/profile/profile'; import { StyledButtonContainer, diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/RevokeTrustedDevicesDialog.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/RevokeTrustedDevicesDialog.tsx index 0c11363f65c..7558603e818 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/RevokeTrustedDevicesDialog.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/RevokeTrustedDevicesDialog.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Typography } from 'src/components/Typography'; -import { useRevokeTrustedDeviceMutation } from 'src/queries/profile'; +import { useRevokeTrustedDeviceMutation } from 'src/queries/profile/profile'; interface Props { deviceId: number; diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/SMSMessaging.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/SMSMessaging.tsx index 77e4b7a5e4b..44c6969dcb7 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/SMSMessaging.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/SMSMessaging.tsx @@ -9,8 +9,8 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; -import { useSMSOptOutMutation } from 'src/queries/profile'; -import { useProfile } from 'src/queries/profile'; +import { useSMSOptOutMutation } from 'src/queries/profile/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getFormattedNumber } from './PhoneVerification/helpers'; diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/SecurityQuestions/SecurityQuestions.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/SecurityQuestions/SecurityQuestions.tsx index ad753e4c84e..d84d9002e53 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/SecurityQuestions/SecurityQuestions.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/SecurityQuestions/SecurityQuestions.tsx @@ -12,7 +12,7 @@ import { Typography } from 'src/components/Typography'; import { useMutateSecurityQuestions, useSecurityQuestions, -} from 'src/queries/securityQuestions'; +} from 'src/queries/profile/securityQuestions'; import { QuestionAndAnswerPair } from './QuestionAndAnswerPair'; import { getAnsweredQuestions, securityQuestionsToItems } from './utilities'; diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx index 1018568b59f..3279aa424e5 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/TrustedDevices.tsx @@ -17,7 +17,7 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { Typography } from 'src/components/Typography'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; -import { useTrustedDevicesQuery } from 'src/queries/profile'; +import { useTrustedDevicesQuery } from 'src/queries/profile/profile'; import { RevokeTrustedDeviceDialog } from './RevokeTrustedDevicesDialog'; diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/DisableTwoFactorDialog.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/DisableTwoFactorDialog.tsx index 95f722c9564..f60e346f1ec 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/DisableTwoFactorDialog.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/DisableTwoFactorDialog.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Typography } from 'src/components/Typography'; -import { useDisableTwoFactorMutation } from 'src/queries/profile'; +import { useDisableTwoFactorMutation } from 'src/queries/profile/profile'; interface Props { onClose: () => void; diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/TwoFactor.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/TwoFactor.tsx index e36913a0852..d21fb6961bf 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/TwoFactor.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/TwoFactor.tsx @@ -6,7 +6,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; -import { useSecurityQuestions } from 'src/queries/securityQuestions'; +import { useSecurityQuestions } from 'src/queries/profile/securityQuestions'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; @@ -19,7 +19,7 @@ import { StyledRootContainer, } from './TwoFactor.styles'; import { TwoFactorToggle } from './TwoFactorToggle'; -import { profileQueries } from 'src/queries/profile'; +import { profileQueries } from 'src/queries/profile/profile'; export interface TwoFactorProps { disabled?: boolean; diff --git a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.test.tsx b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.test.tsx index acd777fd456..2008bf9c339 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.test.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.test.tsx @@ -9,8 +9,8 @@ const queryMocks = vi.hoisted(() => ({ useProfile: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/profile', async () => { - const actual = await vi.importActual('src/queries/profile'); +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); return { ...actual, useProfile: queryMocks.useProfile, diff --git a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx index 87ad2378350..564aae521ff 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx @@ -15,7 +15,7 @@ import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; import { useNotificationsQuery } from 'src/queries/account/notifications'; -import { useMutateProfile, useProfile } from 'src/queries/profile'; +import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; import { ApplicationState } from 'src/store'; import { sendManageGravatarEvent } from 'src/utilities/analytics/customEventAnalytics'; diff --git a/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx b/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx index 65c41cc514e..9a1cd6bb1d1 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx @@ -9,7 +9,7 @@ import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress'; import Select, { Item } from 'src/components/EnhancedSelect/Select'; import { Typography } from 'src/components/Typography'; -import { useMutateProfile, useProfile } from 'src/queries/profile'; +import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; interface Props { loggedInAsCustomer: boolean; diff --git a/packages/manager/src/features/Profile/LishSettings/LishSettings.tsx b/packages/manager/src/features/Profile/LishSettings/LishSettings.tsx index 56cafc8d409..1aa5aaa06d5 100644 --- a/packages/manager/src/features/Profile/LishSettings/LishSettings.tsx +++ b/packages/manager/src/features/Profile/LishSettings/LishSettings.tsx @@ -14,7 +14,7 @@ import { Paper } from 'src/components/Paper'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { FormControl } from 'src/components/FormControl'; -import { useMutateProfile, useProfile } from 'src/queries/profile'; +import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; diff --git a/packages/manager/src/features/Profile/Referrals/Referrals.tsx b/packages/manager/src/features/Profile/Referrals/Referrals.tsx index 763b35a0a06..f3bac05580f 100644 --- a/packages/manager/src/features/Profile/Referrals/Referrals.tsx +++ b/packages/manager/src/features/Profile/Referrals/Referrals.tsx @@ -11,7 +11,7 @@ import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { Typography } from 'src/components/Typography'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { diff --git a/packages/manager/src/features/Profile/SSHKeys/CreateSSHKeyDrawer.tsx b/packages/manager/src/features/Profile/SSHKeys/CreateSSHKeyDrawer.tsx index de17e09d68e..6073e28691a 100644 --- a/packages/manager/src/features/Profile/SSHKeys/CreateSSHKeyDrawer.tsx +++ b/packages/manager/src/features/Profile/SSHKeys/CreateSSHKeyDrawer.tsx @@ -9,7 +9,7 @@ import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; -import { useCreateSSHKeyMutation } from 'src/queries/profile'; +import { useCreateSSHKeyMutation } from 'src/queries/profile/profile'; import { handleFormikBlur } from 'src/utilities/formikTrimUtil'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; diff --git a/packages/manager/src/features/Profile/SSHKeys/DeleteSSHKeyDialog.tsx b/packages/manager/src/features/Profile/SSHKeys/DeleteSSHKeyDialog.tsx index 7aa868f566d..7b72b3913d7 100644 --- a/packages/manager/src/features/Profile/SSHKeys/DeleteSSHKeyDialog.tsx +++ b/packages/manager/src/features/Profile/SSHKeys/DeleteSSHKeyDialog.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Typography } from 'src/components/Typography'; -import { useDeleteSSHKeyMutation } from 'src/queries/profile'; +import { useDeleteSSHKeyMutation } from 'src/queries/profile/profile'; interface Props { id: number; diff --git a/packages/manager/src/features/Profile/SSHKeys/EditSSHKeyDrawer.tsx b/packages/manager/src/features/Profile/SSHKeys/EditSSHKeyDrawer.tsx index 0861f2e38da..a1c09874fae 100644 --- a/packages/manager/src/features/Profile/SSHKeys/EditSSHKeyDrawer.tsx +++ b/packages/manager/src/features/Profile/SSHKeys/EditSSHKeyDrawer.tsx @@ -8,7 +8,7 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; -import { useUpdateSSHKeyMutation } from 'src/queries/profile'; +import { useUpdateSSHKeyMutation } from 'src/queries/profile/profile'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; interface Props { diff --git a/packages/manager/src/features/Profile/SSHKeys/SSHKeys.tsx b/packages/manager/src/features/Profile/SSHKeys/SSHKeys.tsx index 1bec2a5ca14..91afe803212 100644 --- a/packages/manager/src/features/Profile/SSHKeys/SSHKeys.tsx +++ b/packages/manager/src/features/Profile/SSHKeys/SSHKeys.tsx @@ -18,7 +18,7 @@ import { Typography } from 'src/components/Typography'; import DeleteSSHKeyDialog from 'src/features/Profile/SSHKeys/DeleteSSHKeyDialog'; import SSHKeyActionMenu from 'src/features/Profile/SSHKeys/SSHKeyActionMenu'; import { usePagination } from 'src/hooks/usePagination'; -import { useSSHKeysQuery } from 'src/queries/profile'; +import { useSSHKeysQuery } from 'src/queries/profile/profile'; import { parseAPIDate } from 'src/utilities/date'; import { getSSHKeyFingerprint } from 'src/utilities/ssh-fingerprint'; diff --git a/packages/manager/src/features/Profile/Settings/PreferenceEditor.tsx b/packages/manager/src/features/Profile/Settings/PreferenceEditor.tsx index 6eb633a8465..dd2fc9ac06c 100644 --- a/packages/manager/src/features/Profile/Settings/PreferenceEditor.tsx +++ b/packages/manager/src/features/Profile/Settings/PreferenceEditor.tsx @@ -6,7 +6,10 @@ import { Dialog, DialogProps } from 'src/components/Dialog/Dialog'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; type Props = Pick; diff --git a/packages/manager/src/features/Profile/Settings/Settings.tsx b/packages/manager/src/features/Profile/Settings/Settings.tsx index 61aa3b0726b..f383fdb5279 100644 --- a/packages/manager/src/features/Profile/Settings/Settings.tsx +++ b/packages/manager/src/features/Profile/Settings/Settings.tsx @@ -10,8 +10,11 @@ import { RadioGroup } from 'src/components/RadioGroup'; import { Stack } from 'src/components/Stack'; import { Toggle } from 'src/components/Toggle/Toggle'; import { Typography } from 'src/components/Typography'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; -import { useMutateProfile, useProfile } from 'src/queries/profile'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; +import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; import { getQueryParamFromQueryString } from 'src/utilities/queryParams'; import { ThemeChoice } from 'src/utilities/theme'; import { isOSMac } from 'src/utilities/userAgent'; diff --git a/packages/manager/src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptsSection.tsx b/packages/manager/src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptsSection.tsx index 0d1830bd647..a207afdc66b 100644 --- a/packages/manager/src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptsSection.tsx +++ b/packages/manager/src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptsSection.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { CircleProgress } from 'src/components/CircleProgress'; import { TableBody } from 'src/components/TableBody'; import { TableRow } from 'src/components/TableRow'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { formatDate } from 'src/utilities/formatDate'; import { truncate } from 'src/utilities/truncate'; diff --git a/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx b/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx index bebbc1f6b8e..bc39cd3ba1c 100644 --- a/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptCreate/StackScriptCreate.tsx @@ -37,7 +37,7 @@ import { filterImagesByType } from 'src/store/image/image.helpers'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { storage } from 'src/utilities/storage'; -import { profileQueries } from 'src/queries/profile'; +import { profileQueries } from 'src/queries/profile/profile'; interface State { apiResponse?: StackScript; diff --git a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx index 9e6a96efd8c..6fd4ddfb56b 100644 --- a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptActionMenu.tsx @@ -6,7 +6,7 @@ import { useHistory } from 'react-router-dom'; import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { Hidden } from 'src/components/Hidden'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { StackScriptCategory, getStackScriptUrl } from '../stackScriptUtils'; diff --git a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptPanel.tsx b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptPanel.tsx index 65bea35b05b..d8874623669 100644 --- a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptPanel.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptPanel.tsx @@ -6,7 +6,7 @@ import { compose } from 'recompose'; import { NavTab, NavTabs } from 'src/components/NavTabs/NavTabs'; import { RenderGuard } from 'src/components/RenderGuard'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getCommunityStackscripts, diff --git a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptsSection.tsx b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptsSection.tsx index 53006820b7b..cb6c1bce806 100644 --- a/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptsSection.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptPanel/StackScriptsSection.tsx @@ -9,7 +9,7 @@ import { StackScriptCategory, canUserModifyAccountStackScript, } from 'src/features/StackScripts/stackScriptUtils'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { formatDate } from 'src/utilities/formatDate'; import { stripImageName } from 'src/utilities/stripImageName'; diff --git a/packages/manager/src/features/StackScripts/StackScriptsDetail.tsx b/packages/manager/src/features/StackScripts/StackScriptsDetail.tsx index 37fc05b2f30..3ea0651917c 100644 --- a/packages/manager/src/features/StackScripts/StackScriptsDetail.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptsDetail.tsx @@ -12,7 +12,7 @@ import { LandingHeader } from 'src/components/LandingHeader'; import { NotFound } from 'src/components/NotFound'; import { StackScript as _StackScript } from 'src/components/StackScript/StackScript'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; import { diff --git a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx index 28dcbb0ebfe..2b2f0be3a77 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/SupportTicketDetail.tsx @@ -11,7 +11,7 @@ import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; import { Stack } from 'src/components/Stack'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { useInfiniteSupportTicketRepliesQuery, useSupportTicketQuery, diff --git a/packages/manager/src/features/Support/SupportTicketDetail/TicketStatus.tsx b/packages/manager/src/features/Support/SupportTicketDetail/TicketStatus.tsx index 954f8b0d4cc..79f3d52f439 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/TicketStatus.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/TicketStatus.tsx @@ -9,7 +9,7 @@ import { Paper } from 'src/components/Paper'; import { Stack } from 'src/components/Stack'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { Typography } from 'src/components/Typography'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { capitalize } from 'src/utilities/capitalize'; import { formatDate } from 'src/utilities/formatDate'; import { getLinkTargets } from 'src/utilities/getEventsActionLink'; diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index 2cfd5d58f69..61218835c21 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -23,7 +23,7 @@ import { SwitchAccountDrawer } from 'src/features/Account/SwitchAccountDrawer'; import { useIsParentTokenExpired } from 'src/features/Account/SwitchAccounts/useIsParentTokenExpired'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useAccount } from 'src/queries/account/account'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { sendSwitchAccountEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getStorage, setStorage } from 'src/utilities/storage'; diff --git a/packages/manager/src/features/Users/UserDetail.tsx b/packages/manager/src/features/Users/UserDetail.tsx index f23e351399f..292eafa1756 100644 --- a/packages/manager/src/features/Users/UserDetail.tsx +++ b/packages/manager/src/features/Users/UserDetail.tsx @@ -20,7 +20,7 @@ import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { accountQueries } from 'src/queries/account/queries'; import { useAccountUser } from 'src/queries/account/users'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import UserPermissions from './UserPermissions'; diff --git a/packages/manager/src/features/Users/UserProfile.tsx b/packages/manager/src/features/Users/UserProfile.tsx index 710c7b48b1e..6f7f7b1f2da 100644 --- a/packages/manager/src/features/Users/UserProfile.tsx +++ b/packages/manager/src/features/Users/UserProfile.tsx @@ -11,7 +11,7 @@ import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { useAccountUser } from 'src/queries/account/users'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; import { PARENT_USER, RESTRICTED_FIELD_TOOLTIP } from '../Account/constants'; diff --git a/packages/manager/src/features/Users/UserRow.tsx b/packages/manager/src/features/Users/UserRow.tsx index 2f959791f2c..51745389d36 100644 --- a/packages/manager/src/features/Users/UserRow.tsx +++ b/packages/manager/src/features/Users/UserRow.tsx @@ -11,7 +11,7 @@ import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; import { useAccountUserGrants } from 'src/queries/account/users'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { capitalize } from 'src/utilities/capitalize'; import { UsersActionMenu } from './UsersActionMenu'; diff --git a/packages/manager/src/features/Users/UsersActionMenu.tsx b/packages/manager/src/features/Users/UsersActionMenu.tsx index 4f0bdad2cec..d2d5ea859cc 100644 --- a/packages/manager/src/features/Users/UsersActionMenu.tsx +++ b/packages/manager/src/features/Users/UsersActionMenu.tsx @@ -5,7 +5,7 @@ import { useHistory } from 'react-router-dom'; import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; interface Props { isProxyUser: boolean; diff --git a/packages/manager/src/features/Users/UsersLanding.tsx b/packages/manager/src/features/Users/UsersLanding.tsx index f927a5dfe8c..08a74f61930 100644 --- a/packages/manager/src/features/Users/UsersLanding.tsx +++ b/packages/manager/src/features/Users/UsersLanding.tsx @@ -14,7 +14,7 @@ import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useAccountUsers } from 'src/queries/account/users'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import CreateUserDrawer from './CreateUserDrawer'; import { UserDeleteConfirmationDialog } from './UserDeleteConfirmationDialog'; diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx index 2a8a854ca64..20827c61afa 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetAssignLinodesDrawer.tsx @@ -27,7 +27,7 @@ import { useFormattedDate } from 'src/hooks/useFormattedDate'; import { useUnassignLinode } from 'src/hooks/useUnassignLinode'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { getAllLinodeConfigs } from 'src/queries/linodes/requests'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getErrorMap } from 'src/utilities/errorUtils'; import { ExtendedIP } from 'src/utilities/ipUtils'; import { SUBNET_LINODE_CSV_HEADERS } from 'src/utilities/subnets'; diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx index 3bd1be7a28a..f4b3a9527cb 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { useCreateSubnetMutation, useVPCQuery } from 'src/queries/vpcs/vpcs'; import { getErrorMap } from 'src/utilities/errorUtils'; import { diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx index e90b6bc1a83..e5b5279fbf4 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx @@ -7,7 +7,7 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { useUpdateSubnetMutation } from 'src/queries/vpcs/vpcs'; import { getErrorMap } from 'src/utilities/errorUtils'; diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx index ed4fa97d7d6..99de08eabab 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetUnassignLinodesDrawer.tsx @@ -19,7 +19,7 @@ import { useAllLinodesQuery, } from 'src/queries/linodes/linodes'; import { getAllLinodeConfigs } from 'src/queries/linodes/requests'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { SUBNET_LINODE_CSV_HEADERS } from 'src/utilities/subnets'; import type { diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx index 83de5473f36..edcd25709f5 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx @@ -8,7 +8,7 @@ import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { TextField } from 'src/components/TextField'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useUpdateVPCMutation } from 'src/queries/vpcs/vpcs'; import { getErrorMap } from 'src/utilities/errorUtils'; diff --git a/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx b/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx index 00415bd3ef8..e19b118990a 100644 --- a/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx @@ -11,7 +11,7 @@ import { FormHelperText } from 'src/components/FormHelperText'; import { Notice } from 'src/components/Notice/Notice'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { useEventsPollingActions } from 'src/queries/events/events'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { useAttachVolumeMutation } from 'src/queries/volumes/volumes'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; diff --git a/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx b/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx index 2f54ad91c68..9304ab01c1f 100644 --- a/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx @@ -9,7 +9,7 @@ import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { useEventsPollingActions } from 'src/queries/events/events'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { useCloneVolumeMutation, useVolumeTypesQuery, diff --git a/packages/manager/src/features/Volumes/EditVolumeDrawer.tsx b/packages/manager/src/features/Volumes/EditVolumeDrawer.tsx index 365e2261029..b4cf478c120 100644 --- a/packages/manager/src/features/Volumes/EditVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/EditVolumeDrawer.tsx @@ -8,7 +8,7 @@ import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { useUpdateVolumeMutation } from 'src/queries/volumes/volumes'; import { handleFieldErrors, diff --git a/packages/manager/src/features/Volumes/ResizeVolumeDrawer.tsx b/packages/manager/src/features/Volumes/ResizeVolumeDrawer.tsx index 79a168ea377..8a9f030f1c0 100644 --- a/packages/manager/src/features/Volumes/ResizeVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/ResizeVolumeDrawer.tsx @@ -8,7 +8,7 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { useEventsPollingActions } from 'src/queries/events/events'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { useResizeVolumeMutation, useVolumeTypesQuery, diff --git a/packages/manager/src/features/Volumes/VolumeCreate.tsx b/packages/manager/src/features/Volumes/VolumeCreate.tsx index 8b611c74ea1..ed14048a42e 100644 --- a/packages/manager/src/features/Volumes/VolumeCreate.tsx +++ b/packages/manager/src/features/Volumes/VolumeCreate.tsx @@ -25,7 +25,7 @@ import { useAccountAgreements, useMutateAccountAgreements, } from 'src/queries/account/agreements'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useCreateVolumeMutation, diff --git a/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAttachForm.tsx b/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAttachForm.tsx index 3d75171ea14..e7a45c3b3e8 100644 --- a/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAttachForm.tsx +++ b/packages/manager/src/features/Volumes/VolumeDrawer/LinodeVolumeAttachForm.tsx @@ -8,7 +8,7 @@ import { number, object } from 'yup'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Notice } from 'src/components/Notice/Notice'; import { useEventsPollingActions } from 'src/queries/events/events'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { useAttachVolumeMutation } from 'src/queries/volumes/volumes'; import { handleFieldErrors, diff --git a/packages/manager/src/hooks/useAccountManagement.ts b/packages/manager/src/hooks/useAccountManagement.ts index 3501b7f0951..1829a3ca9ee 100644 --- a/packages/manager/src/hooks/useAccountManagement.ts +++ b/packages/manager/src/hooks/useAccountManagement.ts @@ -2,7 +2,7 @@ import { GlobalGrantTypes } from '@linode/api-v4/lib/account'; import { useAccount } from 'src/queries/account/account'; import { useAccountSettings } from 'src/queries/account/settings'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; export const useAccountManagement = () => { const { data: account, error: accountError } = useAccount(); diff --git a/packages/manager/src/hooks/useCreateVPC.ts b/packages/manager/src/hooks/useCreateVPC.ts index 3343ba59e0b..2ba217f9d8a 100644 --- a/packages/manager/src/hooks/useCreateVPC.ts +++ b/packages/manager/src/hooks/useCreateVPC.ts @@ -8,7 +8,7 @@ import { useFormik } from 'formik'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useCreateVPCMutation } from 'src/queries/vpcs/vpcs'; import { diff --git a/packages/manager/src/hooks/useDismissibleNotifications.ts b/packages/manager/src/hooks/useDismissibleNotifications.ts index d87f35b0ce3..56cfc021664 100644 --- a/packages/manager/src/hooks/useDismissibleNotifications.ts +++ b/packages/manager/src/hooks/useDismissibleNotifications.ts @@ -2,7 +2,10 @@ import { DateTime } from 'luxon'; import md5 from 'md5'; import { useState } from 'react'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; import { DismissedNotification } from 'src/types/ManagerPreferences'; /** diff --git a/packages/manager/src/hooks/useEventHandlers.ts b/packages/manager/src/hooks/useEventHandlers.ts index a9667b9102a..56c8b6bad9e 100644 --- a/packages/manager/src/hooks/useEventHandlers.ts +++ b/packages/manager/src/hooks/useEventHandlers.ts @@ -8,10 +8,10 @@ import { imageEventsHandler } from 'src/queries/images'; import { diskEventHandler } from 'src/queries/linodes/events'; import { linodeEventsHandler } from 'src/queries/linodes/events'; import { nodebalanacerEventHandler } from 'src/queries/nodebalancers'; -import { sshKeyEventHandler } from 'src/queries/profile'; +import { sshKeyEventHandler } from 'src/queries/profile/profile'; import { stackScriptEventHandler } from 'src/queries/stackscripts'; import { supportTicketEventHandler } from 'src/queries/support'; -import { tokenEventHandler } from 'src/queries/tokens'; +import { tokenEventHandler } from 'src/queries/profile/tokens'; import { volumeEventsHandler } from 'src/queries/volumes/events'; import type { Event } from '@linode/api-v4'; diff --git a/packages/manager/src/hooks/useGlobalKeyboardListener.ts b/packages/manager/src/hooks/useGlobalKeyboardListener.ts index 0a632cf5f01..e95e600f1e4 100644 --- a/packages/manager/src/hooks/useGlobalKeyboardListener.ts +++ b/packages/manager/src/hooks/useGlobalKeyboardListener.ts @@ -1,6 +1,9 @@ import React from 'react'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; import { getNextThemeValue } from 'src/utilities/theme'; import { isOSMac } from 'src/utilities/userAgent'; diff --git a/packages/manager/src/hooks/useInitialRequests.ts b/packages/manager/src/hooks/useInitialRequests.ts index 25202e587bf..ec1dca356d6 100644 --- a/packages/manager/src/hooks/useInitialRequests.ts +++ b/packages/manager/src/hooks/useInitialRequests.ts @@ -5,7 +5,7 @@ import * as React from 'react'; import { useAuthentication } from 'src/hooks/useAuthentication'; import { usePendingUpload } from 'src/hooks/usePendingUpload'; import { accountQueries } from 'src/queries/account/queries'; -import { profileQueries } from 'src/queries/profile'; +import { profileQueries } from 'src/queries/profile/profile'; import { redirectToLogin } from 'src/session'; /** diff --git a/packages/manager/src/hooks/useIsResourceRestricted.ts b/packages/manager/src/hooks/useIsResourceRestricted.ts index e9126cf36af..997fcf6afb2 100644 --- a/packages/manager/src/hooks/useIsResourceRestricted.ts +++ b/packages/manager/src/hooks/useIsResourceRestricted.ts @@ -1,4 +1,4 @@ -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import type { GrantLevel, GrantType } from '@linode/api-v4'; diff --git a/packages/manager/src/hooks/useOrder.test.tsx b/packages/manager/src/hooks/useOrder.test.tsx index e3776006646..5a31d40d4fe 100644 --- a/packages/manager/src/hooks/useOrder.test.tsx +++ b/packages/manager/src/hooks/useOrder.test.tsx @@ -3,7 +3,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { queryClientFactory } from 'src/queries/base'; -import { usePreferences } from 'src/queries/preferences'; +import { usePreferences } from 'src/queries/profile/preferences'; import { OrderSet } from 'src/types/ManagerPreferences'; import { wrapWithTheme } from 'src/utilities/testHelpers'; diff --git a/packages/manager/src/hooks/useOrder.ts b/packages/manager/src/hooks/useOrder.ts index ce31f94a334..0a9f195c57e 100644 --- a/packages/manager/src/hooks/useOrder.ts +++ b/packages/manager/src/hooks/useOrder.ts @@ -3,7 +3,10 @@ import { useHistory, useLocation } from 'react-router-dom'; import { debounce } from 'throttle-debounce'; import { getInitialValuesFromUserPreferences } from 'src/components/OrderBy'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; import { OrderSet } from 'src/types/ManagerPreferences'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; diff --git a/packages/manager/src/hooks/usePagination.ts b/packages/manager/src/hooks/usePagination.ts index ce90c3c1272..6095ef68bcc 100644 --- a/packages/manager/src/hooks/usePagination.ts +++ b/packages/manager/src/hooks/usePagination.ts @@ -1,7 +1,10 @@ import { useHistory, useLocation } from 'react-router-dom'; import { MIN_PAGE_SIZE } from 'src/components/PaginationFooter/PaginationFooter'; -import { useMutatePreferences, usePreferences } from 'src/queries/preferences'; +import { + useMutatePreferences, + usePreferences, +} from 'src/queries/profile/preferences'; export interface PaginationProps { handlePageChange: (page: number) => void; diff --git a/packages/manager/src/hooks/useRestrictedGlobalGrantCheck.test.ts b/packages/manager/src/hooks/useRestrictedGlobalGrantCheck.test.ts index b3ffd123797..6c298817a24 100644 --- a/packages/manager/src/hooks/useRestrictedGlobalGrantCheck.test.ts +++ b/packages/manager/src/hooks/useRestrictedGlobalGrantCheck.test.ts @@ -7,8 +7,8 @@ const queryMocks = vi.hoisted(() => ({ useProfile: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/profile', async () => { - const actual = await vi.importActual('src/queries/profile'); +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); return { ...actual, useGrants: queryMocks.useGrants, diff --git a/packages/manager/src/hooks/useRestrictedGlobalGrantCheck.ts b/packages/manager/src/hooks/useRestrictedGlobalGrantCheck.ts index d8243ad9806..266a0ef879e 100644 --- a/packages/manager/src/hooks/useRestrictedGlobalGrantCheck.ts +++ b/packages/manager/src/hooks/useRestrictedGlobalGrantCheck.ts @@ -1,4 +1,4 @@ -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import type { RestrictedGlobalGrantType } from 'src/features/Account/utils'; diff --git a/packages/manager/src/queries/account/account.ts b/packages/manager/src/queries/account/account.ts index 4d520314936..bbfa0714791 100644 --- a/packages/manager/src/queries/account/account.ts +++ b/packages/manager/src/queries/account/account.ts @@ -9,7 +9,7 @@ import { useQueryClient, } from '@tanstack/react-query'; -import { useGrants, useProfile } from 'src/queries/profile'; +import { useGrants, useProfile } from 'src/queries/profile/profile'; import { queryPresets } from '../base'; import { accountQueries } from './queries'; diff --git a/packages/manager/src/queries/account/agreements.ts b/packages/manager/src/queries/account/agreements.ts index 09db7945afa..4b754a2d950 100644 --- a/packages/manager/src/queries/account/agreements.ts +++ b/packages/manager/src/queries/account/agreements.ts @@ -2,7 +2,7 @@ import { signAgreement } from '@linode/api-v4'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { reportException } from 'src/exceptionReporting'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { queryPresets } from '../base'; import { accountQueries } from './queries'; diff --git a/packages/manager/src/queries/account/payment.ts b/packages/manager/src/queries/account/payment.ts index 70b4d13c455..5db52a3a581 100644 --- a/packages/manager/src/queries/account/payment.ts +++ b/packages/manager/src/queries/account/payment.ts @@ -6,7 +6,7 @@ import { import { APIError } from '@linode/api-v4/lib/types'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useGrants } from 'src/queries/profile'; +import { useGrants } from 'src/queries/profile/profile'; import { queryPresets } from '../base'; import { accountQueries } from './queries'; diff --git a/packages/manager/src/queries/account/settings.ts b/packages/manager/src/queries/account/settings.ts index 77e894c32ad..27c8b1adc72 100644 --- a/packages/manager/src/queries/account/settings.ts +++ b/packages/manager/src/queries/account/settings.ts @@ -10,7 +10,7 @@ import { useQueryClient, } from '@tanstack/react-query'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { queryPresets } from '../base'; import { accountQueries } from './queries'; diff --git a/packages/manager/src/queries/account/users.ts b/packages/manager/src/queries/account/users.ts index dfe0e764df1..e498baa0b3b 100644 --- a/packages/manager/src/queries/account/users.ts +++ b/packages/manager/src/queries/account/users.ts @@ -1,7 +1,7 @@ import { deleteUser } from '@linode/api-v4/lib/account'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { accountQueries } from './queries'; diff --git a/packages/manager/src/queries/databases/databases.ts b/packages/manager/src/queries/databases/databases.ts index 21d72b0ce1d..4d7413418f2 100644 --- a/packages/manager/src/queries/databases/databases.ts +++ b/packages/manager/src/queries/databases/databases.ts @@ -13,7 +13,7 @@ import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { queryPresets } from '../base'; -import { profileQueries } from '../profile'; +import { profileQueries } from '../profile/profile'; import { getAllDatabaseEngines, getAllDatabaseTypes, diff --git a/packages/manager/src/queries/domains.ts b/packages/manager/src/queries/domains.ts index 0c061adf2f5..fb21b5289fe 100644 --- a/packages/manager/src/queries/domains.ts +++ b/packages/manager/src/queries/domains.ts @@ -25,7 +25,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { getAll } from 'src/utilities/getAll'; -import { profileQueries } from './profile'; +import { profileQueries } from './profile/profile'; export const queryKey = 'domains'; diff --git a/packages/manager/src/queries/entityTransfers.ts b/packages/manager/src/queries/entityTransfers.ts index a0dfee17111..2480d06f81d 100644 --- a/packages/manager/src/queries/entityTransfers.ts +++ b/packages/manager/src/queries/entityTransfers.ts @@ -8,7 +8,7 @@ import { import { APIError, Filter, Params } from '@linode/api-v4/lib/types'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useProfile } from 'src/queries/profile'; +import { useProfile } from 'src/queries/profile/profile'; import { creationHandlers, listToItemsByID, queryPresets } from './base'; diff --git a/packages/manager/src/queries/firewalls.ts b/packages/manager/src/queries/firewalls.ts index 0ff8a8adbed..a328021b773 100644 --- a/packages/manager/src/queries/firewalls.ts +++ b/packages/manager/src/queries/firewalls.ts @@ -27,7 +27,7 @@ import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; import { getAll } from 'src/utilities/getAll'; import { updateInPaginatedStore } from './base'; -import { profileQueries } from './profile'; +import { profileQueries } from './profile/profile'; export const queryKey = 'firewall'; diff --git a/packages/manager/src/queries/images.ts b/packages/manager/src/queries/images.ts index fc5a7c114cb..2318ef0deee 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/manager/src/queries/images.ts @@ -22,7 +22,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { getAll } from 'src/utilities/getAll'; -import { profileQueries } from './profile'; +import { profileQueries } from './profile/profile'; export const getAllImages = ( passedParams: Params = {}, diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index dbae81e7de1..e9573ad5283 100644 --- a/packages/manager/src/queries/kubernetes.ts +++ b/packages/manager/src/queries/kubernetes.ts @@ -37,7 +37,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { getAll } from 'src/utilities/getAll'; import { queryPresets } from './base'; -import { profileQueries } from './profile'; +import { profileQueries } from './profile/profile'; export const kubernetesQueries = createQueryKeys('kubernetes', { cluster: (id: number) => ({ diff --git a/packages/manager/src/queries/linodes/linodes.ts b/packages/manager/src/queries/linodes/linodes.ts index 85266a7d098..34a5549729a 100644 --- a/packages/manager/src/queries/linodes/linodes.ts +++ b/packages/manager/src/queries/linodes/linodes.ts @@ -42,7 +42,7 @@ import { manuallySetVPCConfigInterfacesToActive } from 'src/utilities/configs'; import { accountQueries } from '../account/queries'; import { queryPresets } from '../base'; -import { profileQueries } from '../profile'; +import { profileQueries } from '../profile/profile'; import { vlanQueries } from '../vlans'; import { getAllLinodeKernelsRequest, getAllLinodesRequest } from './requests'; diff --git a/packages/manager/src/queries/nodebalancers.ts b/packages/manager/src/queries/nodebalancers.ts index 6dfdb690d7d..0cfc7afdc16 100644 --- a/packages/manager/src/queries/nodebalancers.ts +++ b/packages/manager/src/queries/nodebalancers.ts @@ -34,7 +34,7 @@ import { getAll } from 'src/utilities/getAll'; import { queryPresets } from './base'; import { itemInListCreationHandler, itemInListMutationHandler } from './base'; -import { profileQueries } from './profile'; +import { profileQueries } from './profile/profile'; import type { APIError, diff --git a/packages/manager/src/queries/placementGroups.ts b/packages/manager/src/queries/placementGroups.ts index 1a599080b3c..4f8a4fefd70 100644 --- a/packages/manager/src/queries/placementGroups.ts +++ b/packages/manager/src/queries/placementGroups.ts @@ -19,7 +19,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { queryKey as linodeQueryKey } from 'src/queries/linodes/linodes'; import { getAll } from 'src/utilities/getAll'; -import { profileQueries } from './profile'; +import { profileQueries } from './profile/profile'; import type { AssignLinodesToPlacementGroupPayload, diff --git a/packages/manager/src/queries/preferences.ts b/packages/manager/src/queries/profile/preferences.ts similarity index 66% rename from packages/manager/src/queries/preferences.ts rename to packages/manager/src/queries/profile/preferences.ts index f99f578931b..086b3e6fcfc 100644 --- a/packages/manager/src/queries/preferences.ts +++ b/packages/manager/src/queries/profile/preferences.ts @@ -1,8 +1,4 @@ -import { - getUserPreferences, - updateUserPreferences, -} from '@linode/api-v4/lib/profile'; -import { APIError } from '@linode/api-v4/lib/types'; +import { updateUserPreferences } from '@linode/api-v4'; import { QueryClient, useMutation, @@ -12,12 +8,14 @@ import { import { ManagerPreferences } from 'src/types/ManagerPreferences'; -import { queryPresets } from './base'; +import { queryPresets } from '../base'; +import { profileQueries } from './profile'; -export const queryKey = 'preferences'; +import type { APIError } from '@linode/api-v4'; export const usePreferences = (enabled = true) => - useQuery([queryKey], getUserPreferences, { + useQuery({ + ...profileQueries.preferences, ...queryPresets.oneTimeFetch, enabled, }); @@ -25,20 +23,19 @@ export const usePreferences = (enabled = true) => export const useMutatePreferences = (replace = false) => { const { data: preferences } = usePreferences(!replace); const queryClient = useQueryClient(); + return useMutation< ManagerPreferences, APIError[], Partial - >( - (data) => + >({ + mutationFn: (data) => updateUserPreferences({ ...(!replace && preferences !== undefined ? preferences : {}), ...data, }), - { - onMutate: (data) => updatePreferenceData(data, replace, queryClient), - } - ); + onMutate: (data) => updatePreferenceData(data, replace, queryClient), + }); }; export const updatePreferenceData = ( @@ -47,8 +44,8 @@ export const updatePreferenceData = ( queryClient: QueryClient ): void => { queryClient.setQueryData( - [queryKey], - (oldData: ManagerPreferences) => ({ + profileQueries.preferences.queryKey, + (oldData) => ({ ...(!replace ? oldData : {}), ...newData, }) diff --git a/packages/manager/src/queries/profile.ts b/packages/manager/src/queries/profile/profile.ts similarity index 94% rename from packages/manager/src/queries/profile.ts rename to packages/manager/src/queries/profile/profile.ts index 2306c075962..cc57db89c4e 100644 --- a/packages/manager/src/queries/profile.ts +++ b/packages/manager/src/queries/profile/profile.ts @@ -1,31 +1,25 @@ import { Profile, SSHKey, - SendPhoneVerificationCodePayload, TrustedDevice, - VerifyVerificationCodePayload, createSSHKey, deleteSSHKey, deleteTrustedDevice, disableTwoFactor, + getAppTokens, + getPersonalAccessTokens, getProfile, getSSHKeys, + getSecurityQuestions, getTrustedDevices, + getUserPreferences, listGrants, sendCodeToPhoneNumber, smsOptOut, updateProfile, updateSSHKey, verifyPhoneNumberCode, - getAppTokens, - getPersonalAccessTokens, -} from '@linode/api-v4/lib/profile'; -import { - APIError, - Filter, - Params, - ResourcePage, -} from '@linode/api-v4/lib/types'; +} from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { QueryClient, @@ -36,11 +30,19 @@ import { import { EventHandlerData } from 'src/hooks/useEventHandlers'; -import { Grants } from '../../../api-v4/lib'; -import { accountQueries } from './account/queries'; -import { queryPresets } from './base'; +import { accountQueries } from '../account/queries'; +import { queryPresets } from '../base'; -import type { RequestOptions } from '@linode/api-v4'; +import type { + APIError, + Filter, + Grants, + Params, + RequestOptions, + ResourcePage, + SendPhoneVerificationCodePayload, + VerifyVerificationCodePayload, +} from '@linode/api-v4'; export const profileQueries = createQueryKeys('profile', { appTokens: (params: Params = {}, filter: Filter = {}) => ({ @@ -55,10 +57,18 @@ export const profileQueries = createQueryKeys('profile', { queryFn: () => getPersonalAccessTokens(params, filter), queryKey: [params, filter], }), + preferences: { + queryFn: getUserPreferences, + queryKey: null, + }, profile: (options: RequestOptions = {}) => ({ queryFn: () => getProfile(options), queryKey: [options], }), + securityQuestions: { + queryFn: getSecurityQuestions, + queryKey: null, + }, sshKeys: (params: Params = {}, filter: Filter = {}) => ({ queryFn: () => getSSHKeys(params, filter), queryKey: [params, filter], diff --git a/packages/manager/src/queries/profile/securityQuestions.ts b/packages/manager/src/queries/profile/securityQuestions.ts new file mode 100644 index 00000000000..78109ac37f5 --- /dev/null +++ b/packages/manager/src/queries/profile/securityQuestions.ts @@ -0,0 +1,79 @@ +import { updateSecurityQuestions } from '@linode/api-v4'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { queryPresets } from '../base'; +import { profileQueries } from './profile'; + +import type { + APIError, + SecurityQuestionsData, + SecurityQuestionsPayload, +} from '@linode/api-v4'; + +export const useSecurityQuestions = ({ + enabled = true, +}: { + enabled?: boolean; +} = {}) => { + return useQuery({ + ...profileQueries.securityQuestions, + ...queryPresets.oneTimeFetch, + enabled, + }); +}; + +export const useMutateSecurityQuestions = () => { + const queryClient = useQueryClient(); + return useMutation< + SecurityQuestionsPayload, + APIError[], + SecurityQuestionsPayload + >({ + mutationFn: updateSecurityQuestions, + onSuccess: (response) => { + queryClient.setQueryData( + profileQueries.securityQuestions.queryKey, + (oldData) => { + if (oldData === undefined) { + return undefined; + } + + const newQuestions: SecurityQuestionsData['security_questions'] = oldData.security_questions.map( + (item) => ({ + ...item, + response: null, + }) + ); + + for (let i = 0; i < response.security_questions.length; i++) { + const index = oldData.security_questions.findIndex( + (question) => + question.id === response.security_questions[i].question_id + ); + + newQuestions[index].response = + response.security_questions[i].response; + } + + for (let i = 0; i < response.security_questions.length; i++) { + const index = newQuestions.findIndex( + (question) => + question.id === response.security_questions[i].question_id + ); + moveInArray(newQuestions, index, i); + } + + return { + security_questions: newQuestions, + }; + } + ); + }, + }); +}; + +function moveInArray(arr: any[], fromIndex: number, toIndex: number) { + const element = arr[fromIndex]; + arr.splice(fromIndex, 1); + arr.splice(toIndex, 0, element); +} diff --git a/packages/manager/src/queries/tokens.ts b/packages/manager/src/queries/profile/tokens.ts similarity index 100% rename from packages/manager/src/queries/tokens.ts rename to packages/manager/src/queries/profile/tokens.ts diff --git a/packages/manager/src/queries/securityQuestions.ts b/packages/manager/src/queries/securityQuestions.ts deleted file mode 100644 index 7af77613dcc..00000000000 --- a/packages/manager/src/queries/securityQuestions.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { - SecurityQuestionsData, - SecurityQuestionsPayload, - getSecurityQuestions, - updateSecurityQuestions, -} from '@linode/api-v4/lib/profile'; -import { APIError } from '@linode/api-v4/lib/types'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -import { queryPresets } from './base'; - -export const queryKey = 'securityQuestions'; - -export const useSecurityQuestions = ({ - enabled = true, -}: { - enabled?: boolean; -} = {}) => { - return useQuery( - [queryKey], - getSecurityQuestions, - { - ...queryPresets.oneTimeFetch, - enabled, - } - ); -}; - -export const useMutateSecurityQuestions = () => { - const queryClient = useQueryClient(); - return useMutation< - SecurityQuestionsPayload, - APIError[], - SecurityQuestionsPayload - >( - (data) => { - return updateSecurityQuestions(data); - }, - { - onSuccess: (response) => { - queryClient.setQueryData( - [queryKey], - (oldData) => { - if (oldData === undefined) { - return undefined; - } - - const newQuestions: SecurityQuestionsData['security_questions'] = oldData.security_questions.map( - (item) => ({ - ...item, - response: null, - }) - ); - - for (let i = 0; i < response.security_questions.length; i++) { - const index = oldData.security_questions.findIndex( - (question) => - question.id === response.security_questions[i].question_id - ); - - newQuestions[index].response = - response.security_questions[i].response; - } - - for (let i = 0; i < response.security_questions.length; i++) { - const index = newQuestions.findIndex( - (question) => - question.id === response.security_questions[i].question_id - ); - moveInArray(newQuestions, index, i); - } - - return { - security_questions: newQuestions, - }; - } - ); - }, - } - ); -}; - -function moveInArray(arr: any[], fromIndex: number, toIndex: number) { - const element = arr[fromIndex]; - arr.splice(fromIndex, 1); - arr.splice(toIndex, 0, element); -} diff --git a/packages/manager/src/queries/volumes/volumes.ts b/packages/manager/src/queries/volumes/volumes.ts index d2b549100dc..0476d622e1d 100644 --- a/packages/manager/src/queries/volumes/volumes.ts +++ b/packages/manager/src/queries/volumes/volumes.ts @@ -28,7 +28,7 @@ import { import { accountQueries } from '../account/queries'; import { queryPresets } from '../base'; -import { profileQueries } from '../profile'; +import { profileQueries } from '../profile/profile'; import { getAllVolumeTypes, getAllVolumes } from './requests'; export const volumeQueries = createQueryKeys('volumes', { diff --git a/packages/manager/src/useSetupFeatureFlags.ts b/packages/manager/src/useSetupFeatureFlags.ts index bed7ff14e25..1898f3f340c 100644 --- a/packages/manager/src/useSetupFeatureFlags.ts +++ b/packages/manager/src/useSetupFeatureFlags.ts @@ -5,7 +5,7 @@ import { LAUNCH_DARKLY_API_KEY } from 'src/constants'; import { configureErrorReportingUser } from './exceptionReporting'; import { useAccount } from './queries/account/account'; -import { useProfile } from './queries/profile'; +import { useProfile } from './queries/profile/profile'; /** * This hook uses Linode account data to set Sentry and Launch Darkly context. diff --git a/packages/manager/src/utilities/theme.ts b/packages/manager/src/utilities/theme.ts index 322cf68e677..67f66422337 100644 --- a/packages/manager/src/utilities/theme.ts +++ b/packages/manager/src/utilities/theme.ts @@ -5,7 +5,7 @@ import { dark, light } from 'src/foundations/themes'; import type { ThemeName } from 'src/foundations/themes'; import { useAuthentication } from 'src/hooks/useAuthentication'; -import { usePreferences } from 'src/queries/preferences'; +import { usePreferences } from 'src/queries/profile/preferences'; export type ThemeChoice = 'dark' | 'light' | 'system'; From d52ad401564862e2f975fd49cfebf2e900ed2ce9 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Wed, 5 Jun 2024 14:19:46 -0400 Subject: [PATCH 056/163] change: [M3-6541] - eslint: consistent-type-imports (#10540) * eslint: consistent-type-imports * Added changeset: New `consistent-type-imports` es-lint warning * modify changeset --- .../.changeset/pr-10540-tech-stories-1717433580405.md | 5 +++++ packages/manager/.eslintrc.cjs | 1 + 2 files changed, 6 insertions(+) create mode 100644 packages/manager/.changeset/pr-10540-tech-stories-1717433580405.md diff --git a/packages/manager/.changeset/pr-10540-tech-stories-1717433580405.md b/packages/manager/.changeset/pr-10540-tech-stories-1717433580405.md new file mode 100644 index 00000000000..c30e5a2e3b6 --- /dev/null +++ b/packages/manager/.changeset/pr-10540-tech-stories-1717433580405.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +New `consistent-type-imports` es-lint warning ([#10540](https://github.com/linode/manager/pull/10540)) diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index 3c64f9b2c0e..d28ee97f89f 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -115,6 +115,7 @@ module.exports = { rules: { '@linode/cloud-manager/no-custom-fontWeight': 'error', '@typescript-eslint/camelcase': 'off', + "@typescript-eslint/consistent-type-imports": "warn", '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/interface-name-prefix': 'off', From a0c4520ab6f53126835e0cec47bd6a8a6142f491 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-linode@users.noreply.github.com> Date: Wed, 5 Jun 2024 17:00:40 -0400 Subject: [PATCH 057/163] refactor: [M3-8183] - Clean up loading components (#10524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 We have a lot of loading components that are all basically just variations of CircleProgress. This PR cleans up those components. I also moved the overage pricing tooltip text position in the Object Storage Create Bucket drawer to the top so that the tooltip doesn't block the CTA buttons ## How to test 🧪 ### Verification steps (How to verify changes) - Check the affected loading spinners throughout the app --- .../pr-10524-tech-stories-1716999765184.md | 5 ++ .../components/Autocomplete/Autocomplete.tsx | 2 +- .../CircleProgress/CircleProgress.test.tsx | 29 ++---- .../CircleProgress/CircleProgress.tsx | 90 +++++++------------ .../components/CircularProgress.stories.tsx | 17 ---- .../src/components/CircularProgress.tsx | 14 --- .../DebouncedSearchTextField.tsx | 2 +- .../components/LoadingIndicator.tsx | 6 +- .../LandingLoading/LandingLoading.stories.tsx | 25 ------ .../LandingLoading/LandingLoading.test.tsx | 57 ------------ .../LandingLoading/LandingLoading.tsx | 55 ------------ .../manager/src/components/LinkButton.tsx | 12 +-- .../src/components/MenuItem/MenuItem.tsx | 4 - .../TableSortCell/TableSortCell.tsx | 2 +- .../src/components/TagCell/TagCell.tsx | 2 +- packages/manager/src/components/TextField.tsx | 2 +- .../TransferDisplay/TransferDisplay.tsx | 2 +- .../SwitchAccounts/ChildAccountList.tsx | 4 +- .../PaymentDrawer/GooglePayButton.tsx | 2 +- .../PaymentDrawer/PayPalButton.tsx | 2 +- .../PaymentInfoPanel/GooglePayChip.tsx | 2 +- .../PaymentInfoPanel/PayPalChip.tsx | 2 +- .../PaymentInfoPanel/PaymentMethods.tsx | 2 +- .../DatabaseSummaryConnectionDetails.tsx | 2 +- .../Tabs/Backups/BackupSelect.tsx | 8 +- .../Tabs/Marketplace/AppsList.tsx | 4 +- .../Linodes/LinodesCreate/SelectAppPanel.tsx | 4 +- .../LinodeNetworking/IPTransfer.tsx | 2 +- .../LinodeNetworking/LinodeIPAddressRow.tsx | 2 +- .../TransferContent.tsx | 2 +- .../TransferHistory.tsx | 2 +- .../LinodeSettings/LinodeWatchdogPanel.tsx | 5 +- .../LinodeSummary/StatsPanel.tsx | 4 +- .../LoadBalancerConfigurations.tsx | 2 +- .../LongviewDetail/DetailTabs/Disks/Disks.tsx | 4 +- .../LongviewLanding/LongviewPlans.test.tsx | 2 +- .../LongviewLanding/LongviewPlans.tsx | 4 +- .../Managed/SSHAccess/LinodePubKey.tsx | 2 +- .../NodeBalancerSummary/TablesPanel.tsx | 2 +- .../NotificationSection.tsx | 2 +- .../BucketLanding/OveragePricing.tsx | 9 +- .../PlacementGroupsLanding.tsx | 2 +- .../features/Profile/Referrals/Referrals.tsx | 4 +- .../StackScriptBase/StackScriptBase.tsx | 2 +- .../SupportTicketDetail.tsx | 2 +- .../VPCs/VPCDetail/SubnetLinodeRow.tsx | 2 +- .../Volumes/VolumeDrawer/PricePanel.tsx | 17 ++-- .../Volumes/VolumeDrawer/SizeField.tsx | 4 +- .../src/features/Volumes/VolumesLanding.tsx | 4 +- .../manager/src/foundations/themes/light.ts | 3 + 50 files changed, 122 insertions(+), 320 deletions(-) create mode 100644 packages/manager/.changeset/pr-10524-tech-stories-1716999765184.md delete mode 100644 packages/manager/src/components/CircularProgress.stories.tsx delete mode 100644 packages/manager/src/components/CircularProgress.tsx delete mode 100644 packages/manager/src/components/LandingLoading/LandingLoading.stories.tsx delete mode 100644 packages/manager/src/components/LandingLoading/LandingLoading.test.tsx delete mode 100644 packages/manager/src/components/LandingLoading/LandingLoading.tsx diff --git a/packages/manager/.changeset/pr-10524-tech-stories-1716999765184.md b/packages/manager/.changeset/pr-10524-tech-stories-1716999765184.md new file mode 100644 index 00000000000..2e6b8ba7975 --- /dev/null +++ b/packages/manager/.changeset/pr-10524-tech-stories-1716999765184.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Clean up loading components ([#10524](https://github.com/linode/manager/pull/10524)) diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.tsx index cc9a4100c73..90a7202f4d5 100644 --- a/packages/manager/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/manager/src/components/Autocomplete/Autocomplete.tsx @@ -120,7 +120,7 @@ export const Autocomplete = < <> {loading && ( - + )} {textFieldProps?.InputProps?.endAdornment} diff --git a/packages/manager/src/components/CircleProgress/CircleProgress.test.tsx b/packages/manager/src/components/CircleProgress/CircleProgress.test.tsx index b9416f0f1e6..989113ef285 100644 --- a/packages/manager/src/components/CircleProgress/CircleProgress.test.tsx +++ b/packages/manager/src/components/CircleProgress/CircleProgress.test.tsx @@ -15,40 +15,21 @@ describe('CircleProgress', () => { const circle = screen.getByTestId('circle-progress'); expect(circle).toBeInTheDocument(); expect(circle).toHaveStyle('width: 124px; height: 124px;'); - const innerCircle = screen.getByTestId('inner-circle-progress'); - expect(innerCircle).toBeInTheDocument(); }); - it('renders a mini CircleProgress', () => { - const screen = renderWithTheme(); + it('renders a small CircleProgress', () => { + const screen = renderWithTheme(); const circleProgress = screen.getByLabelText(CONTENT_LOADING); expect(circleProgress).toBeVisible(); expect(circleProgress).toHaveStyle('width: 40px; height: 40px;'); }); - it('sets a mini CircleProgress with no padding', () => { - const screen = renderWithTheme(); + it('sets a small CircleProgress with no padding', () => { + const screen = renderWithTheme(); const circleProgress = screen.getByLabelText(CONTENT_LOADING); expect(circleProgress).toBeVisible(); - expect(circleProgress).toHaveStyle('width: 22px; height: 22px;'); - }); - - it('sets a mini CircleProgress with a custom size', () => { - const screen = renderWithTheme(); - - const circleProgress = screen.getByLabelText(CONTENT_LOADING); - expect(circleProgress).toBeVisible(); - expect(circleProgress).toHaveStyle('width: 25px; height: 25px;'); - }); - - it('renders a CircleProgress without the inner circle', () => { - const screen = renderWithTheme(); - - const circleProgress = screen.getByLabelText(CONTENT_LOADING); - expect(circleProgress).toBeVisible(); - const innerCircle = screen.queryByTestId('inner-circle-progress'); - expect(innerCircle).not.toBeInTheDocument(); + expect(circleProgress).toHaveStyle('width: 20px; height: 20px;'); }); }); diff --git a/packages/manager/src/components/CircleProgress/CircleProgress.tsx b/packages/manager/src/components/CircleProgress/CircleProgress.tsx index 24fe6e240fb..115f41c5422 100644 --- a/packages/manager/src/components/CircleProgress/CircleProgress.tsx +++ b/packages/manager/src/components/CircleProgress/CircleProgress.tsx @@ -1,58 +1,63 @@ +import _CircularProgress from '@mui/material/CircularProgress'; import { SxProps, styled } from '@mui/material/styles'; import * as React from 'react'; import { Box } from 'src/components/Box'; -import { - CircularProgress, - CircularProgressProps, -} from 'src/components/CircularProgress'; import { omittedProps } from 'src/utilities/omittedProps'; -interface CircleProgressProps extends CircularProgressProps { +import type { CircularProgressProps } from '@mui/material/CircularProgress'; + +interface CircleProgressProps extends Omit { /** * Additional child elements to pass in */ children?: JSX.Element; /** - * Displays a smaller version of the circle progress. - */ - mini?: boolean; - /** - * If true, will not show an inner circle beneath the spinning circle - */ - noInner?: boolean; - /** - * Removes the padding for `mini` circle progresses only. + * Removes the padding */ noPadding?: boolean; /** - * To be primarily used with mini and noPadding. Set spinner to a custom size. + * Sets the size of the spinner + * @default "lg" */ - size?: number; + size?: 'lg' | 'md' | 'sm' | 'xs'; /** * Additional styles to apply to the root element. */ sx?: SxProps; } +const SIZE_MAP = { + lg: 124, + md: 40, + sm: 20, + xs: 14, +}; + /** - * Use for short, indeterminate activities requiring user attention. + * Use for short, indeterminate activities requiring user attention. Defaults to large. + * + * sizes: + * xs = 14 + * md = 20 + * md = 40 + * lg = 124 */ const CircleProgress = (props: CircleProgressProps) => { - const { children, mini, noInner, noPadding, size, sx, ...rest } = props; + const { children, noPadding, size, sx, ...rest } = props; const variant = typeof props.value === 'number' ? 'determinate' : 'indeterminate'; const value = typeof props.value === 'number' ? props.value : 0; - if (mini) { + if (size) { return ( - ); @@ -63,16 +68,11 @@ const CircleProgress = (props: CircleProgressProps) => { {children !== undefined && ( {children} )} - {noInner !== true && ( - - - - )} ({ width: '100%', })); -const StyledTopWrapperDiv = styled('div')(({}) => ({ - alignItems: 'center', - display: 'flex', - height: '100%', - justifyContent: 'center', - position: 'absolute', - width: '100%', -})); - -const StyledTopDiv = styled('div')(({ theme }) => ({ - border: '1px solid #999', - borderRadius: '50%', - height: 70, - [theme.breakpoints.up('sm')]: { - height: 120, - width: 120, +const StyledCircularProgress = styled(_CircularProgress)(({ theme }) => ({ + position: 'relative', + [theme.breakpoints.down('sm')]: { + height: '72px !important', + width: '72px !important', }, - width: 70, })); -const StyledCircularProgress = styled(CircularProgress)( - ({ theme }) => ({ - position: 'relative', - [theme.breakpoints.down('sm')]: { - height: '72px !important', - width: '72px !important', - }, - }) -); - -const StyledMiniCircularProgress = styled(CircularProgress, { +const StyledCustomCircularProgress = styled(_CircularProgress, { shouldForwardProp: omittedProps(['noPadding']), -})(({ theme, ...props }) => ({ +})<{ noPadding: boolean | undefined }>(({ theme, ...props }) => ({ padding: `calc(${theme.spacing()} * 1.3)`, ...(props.noPadding && { padding: 0, diff --git a/packages/manager/src/components/CircularProgress.stories.tsx b/packages/manager/src/components/CircularProgress.stories.tsx deleted file mode 100644 index db6c4271610..00000000000 --- a/packages/manager/src/components/CircularProgress.stories.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; -import React from 'react'; - -import { CircularProgress } from './CircularProgress'; - -const meta: Meta = { - component: CircularProgress, - title: 'Components/Loading States/Circular Progress', -}; - -type Story = StoryObj; - -export const Default: Story = { - render: (args) => , -}; - -export default meta; diff --git a/packages/manager/src/components/CircularProgress.tsx b/packages/manager/src/components/CircularProgress.tsx deleted file mode 100644 index 95ab3dd9391..00000000000 --- a/packages/manager/src/components/CircularProgress.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import _CircularProgress from '@mui/material/CircularProgress'; -import React from 'react'; - -import type { CircularProgressProps } from '@mui/material/CircularProgress'; - -/** - * Not to be confused with ``. - * @todo Consolidate these two components - */ -export const CircularProgress = (props: CircularProgressProps) => { - return <_CircularProgress {...props} />; -}; - -export type { CircularProgressProps }; diff --git a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx index 0c215e5cf97..cb5c23eafc7 100644 --- a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx +++ b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx @@ -82,7 +82,7 @@ export const DebouncedSearchTextField = React.memo( InputProps={{ endAdornment: isSearching ? ( - + ) : ( clearable && diff --git a/packages/manager/src/components/EnhancedSelect/components/LoadingIndicator.tsx b/packages/manager/src/components/EnhancedSelect/components/LoadingIndicator.tsx index 14a1283fdf7..88cbadaeeb3 100644 --- a/packages/manager/src/components/EnhancedSelect/components/LoadingIndicator.tsx +++ b/packages/manager/src/components/EnhancedSelect/components/LoadingIndicator.tsx @@ -1,13 +1,13 @@ import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { CircularProgress } from 'src/components/CircularProgress'; +import { CircleProgress } from 'src/components/CircleProgress'; export const LoadingIndicator = () => { - return ; + return ; }; -const StyledCircularProgress = styled(CircularProgress)(() => ({ +const StyledCircleProgress = styled(CircleProgress)(() => ({ position: 'relative', right: 20, })); diff --git a/packages/manager/src/components/LandingLoading/LandingLoading.stories.tsx b/packages/manager/src/components/LandingLoading/LandingLoading.stories.tsx deleted file mode 100644 index a57b9a11097..00000000000 --- a/packages/manager/src/components/LandingLoading/LandingLoading.stories.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; - -import { DEFAULT_DELAY, LandingLoading } from './LandingLoading'; - -import type { Meta, StoryObj } from '@storybook/react'; - -const meta: Meta = { - argTypes: {}, - args: { - children: undefined, - delayInMS: DEFAULT_DELAY, - shouldDelay: false, - }, - component: LandingLoading, - title: 'Components/Loading States/LandingLoading', -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: {}, - render: (args) => , -}; diff --git a/packages/manager/src/components/LandingLoading/LandingLoading.test.tsx b/packages/manager/src/components/LandingLoading/LandingLoading.test.tsx deleted file mode 100644 index ac5944a63a3..00000000000 --- a/packages/manager/src/components/LandingLoading/LandingLoading.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { act, render, screen } from '@testing-library/react'; -import * as React from 'react'; - -import { DEFAULT_DELAY, LandingLoading } from './LandingLoading'; - -vi.useFakeTimers(); - -const LOADING_ICON = 'circle-progress'; - -describe('LandingLoading', () => { - afterEach(() => { - vi.clearAllTimers(); - }); - - it('renders the loading indicator by default', () => { - render(); - expect(screen.getByTestId(LOADING_ICON)).toBeInTheDocument(); - }); - - it('renders custom loading indicator when children are provided', () => { - render( - -
    Loading...
    -
    - ); - expect(screen.getByTestId('custom-loading-indicator')).toBeInTheDocument(); - expect(screen.queryByTestId(LOADING_ICON)).toBeNull(); - }); - - it('does not render the loading indicator when shouldDelay is true', () => { - render(); - expect(screen.queryByTestId(LOADING_ICON)).toBeNull(); - }); - - it('renders the loading indicator after the delay', () => { - render(); - expect(screen.queryByTestId(LOADING_ICON)).toBeNull(); - act(() => { - vi.advanceTimersByTime(DEFAULT_DELAY); - }); - expect(screen.getByTestId(LOADING_ICON)).toBeInTheDocument(); - }); - - it('renders the loading indicator after the specified delayInMS', () => { - render(); - expect(screen.queryByTestId(LOADING_ICON)).toBeNull(); - act(() => { - vi.advanceTimersByTime(2000); - }); - expect(screen.getByTestId(LOADING_ICON)).toBeInTheDocument(); - }); - - it('does not render the loading indicator when shouldDelay is false and no delayInMS is provided', () => { - render(); - expect(screen.getByTestId(LOADING_ICON)).toBeInTheDocument(); - }); -}); diff --git a/packages/manager/src/components/LandingLoading/LandingLoading.tsx b/packages/manager/src/components/LandingLoading/LandingLoading.tsx deleted file mode 100644 index 520b04fd21f..00000000000 --- a/packages/manager/src/components/LandingLoading/LandingLoading.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import * as React from 'react'; - -import { CircleProgress } from 'src/components/CircleProgress'; - -export const DEFAULT_DELAY = 1000; - -interface LandingLoadingProps { - /** Allow children to be passed in to override the default loading indicator */ - children?: JSX.Element; - /** If given, the loading indicator will not be rendered for the given duration in milliseconds */ - delayInMS?: number; - /** If true, the loading indicator will not be rendered for 1 second which may give user's with fast connections a more fluid experience. */ - shouldDelay?: boolean; -} - -export const LandingLoading = ({ - children, - delayInMS, - shouldDelay, -}: LandingLoadingProps): JSX.Element | null => { - const [showLoading, setShowLoading] = React.useState(false); - - React.useEffect(() => { - /* This `didCancel` business is to prevent a warning from React. - * See: https://github.com/facebook/react/issues/14369#issuecomment-468267798 - */ - let didCancel = false; - // Reference to the timeoutId so we can cancel it - let timeoutId: NodeJS.Timeout | null = null; - - if (shouldDelay || typeof delayInMS === 'number') { - // Used specified duration or default - const delayDuration = - typeof delayInMS === 'number' ? delayInMS : DEFAULT_DELAY; - - timeoutId = setTimeout(() => { - if (!didCancel) { - setShowLoading(true); - } - }, delayDuration); - } else { - setShowLoading(true); - } - return () => { - didCancel = true; - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - }, [shouldDelay, delayInMS]); - - return showLoading - ? children || - : null; -}; diff --git a/packages/manager/src/components/LinkButton.tsx b/packages/manager/src/components/LinkButton.tsx index 55d118bbc42..4d4c21c25f9 100644 --- a/packages/manager/src/components/LinkButton.tsx +++ b/packages/manager/src/components/LinkButton.tsx @@ -1,10 +1,11 @@ import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; + +import { CircleProgress } from 'src/components/CircleProgress'; import { Box } from './Box'; import { StyledLinkButton } from './Button/StyledLinkButton'; -import { CircularProgress } from './CircularProgress'; const useStyles = makeStyles()((theme: Theme) => ({ disabled: { @@ -12,9 +13,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ cursor: 'default', pointerEvents: 'none', }, - spinner: { - marginLeft: theme.spacing(), - }, })); interface Props { @@ -58,7 +56,9 @@ export const LinkButton = (props: Props) => { return ( {Button} - + + + ); } diff --git a/packages/manager/src/components/MenuItem/MenuItem.tsx b/packages/manager/src/components/MenuItem/MenuItem.tsx index 2473835f403..3d153a1fda0 100644 --- a/packages/manager/src/components/MenuItem/MenuItem.tsx +++ b/packages/manager/src/components/MenuItem/MenuItem.tsx @@ -3,7 +3,6 @@ import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { CircularProgress } from 'src/components/CircularProgress'; import { IconButton } from 'src/components/IconButton'; import { MenuItem, MenuItemProps } from 'src/components/MenuItem'; @@ -88,9 +87,6 @@ export const WrapperMenuItem = (props: WrapperMenuItemCombinedProps) => { {...rest} className={`${classes.root} ${className} ${tooltip && 'hasTooltip'}`} > - {isLoading && ( - - )} {props.children} diff --git a/packages/manager/src/components/TableSortCell/TableSortCell.tsx b/packages/manager/src/components/TableSortCell/TableSortCell.tsx index fd7d860288b..8cd56dd0452 100644 --- a/packages/manager/src/components/TableSortCell/TableSortCell.tsx +++ b/packages/manager/src/components/TableSortCell/TableSortCell.tsx @@ -98,7 +98,7 @@ export const TableSortCell = (props: TableSortCellProps) => { {children} {!active && } - {isLoading && } + {isLoading && } ); }; diff --git a/packages/manager/src/components/TagCell/TagCell.tsx b/packages/manager/src/components/TagCell/TagCell.tsx index a79ea11bd84..7340707d0a9 100644 --- a/packages/manager/src/components/TagCell/TagCell.tsx +++ b/packages/manager/src/components/TagCell/TagCell.tsx @@ -132,7 +132,7 @@ export const TagCell = (props: TagCellProps) => { > {loading ? ( - + ) : null} {tags.map((thisTag) => ( diff --git a/packages/manager/src/components/TextField.tsx b/packages/manager/src/components/TextField.tsx index 15a71b066d7..cdf0a429d6a 100644 --- a/packages/manager/src/components/TextField.tsx +++ b/packages/manager/src/components/TextField.tsx @@ -427,7 +427,7 @@ export const TextField = (props: TextFieldProps) => { disableUnderline: true, endAdornment: loading && ( - + ), ...InputProps, diff --git a/packages/manager/src/components/TransferDisplay/TransferDisplay.tsx b/packages/manager/src/components/TransferDisplay/TransferDisplay.tsx index e05c8ce2448..971ec80d6b7 100644 --- a/packages/manager/src/components/TransferDisplay/TransferDisplay.tsx +++ b/packages/manager/src/components/TransferDisplay/TransferDisplay.tsx @@ -43,7 +43,7 @@ export const TransferDisplay = React.memo(({ spacingTop }: Props) => { {isLoading ? ( <> Loading transfer data... - + ) : ( <> diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx index 7803c0c7573..0e2242a2c18 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx @@ -77,7 +77,7 @@ export const ChildAccountList = React.memo( ) { return ( - + ); } @@ -144,7 +144,7 @@ export const ChildAccountList = React.memo( {!isSwitchingChildAccounts && !isLoading && renderChildAccounts} {hasNextPage && fetchNextPage()} />} - {isFetchingNextPage && } + {isFetchingNextPage && } ); } diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx index 99835eefb88..f9fdb454989 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx @@ -145,7 +145,7 @@ export const GooglePayButton = (props: Props) => { container justifyContent="center" > - +
    ); } diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx index 6da3a6056d7..94b614ffe95 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx @@ -222,7 +222,7 @@ export const PayPalButton = (props: Props) => { container justifyContent="center" > - +
    ); } diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/GooglePayChip.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/GooglePayChip.tsx index 2cc8901c1db..b79ac9dadf7 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/GooglePayChip.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/GooglePayChip.tsx @@ -102,7 +102,7 @@ export const GooglePayChip = (props: Props) => { if (isLoading) { return ( - + ); } diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PayPalChip.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PayPalChip.tsx index 7c76f26d092..b96626489e5 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PayPalChip.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PayPalChip.tsx @@ -161,7 +161,7 @@ export const PayPalChip = (props: Props) => { if (isLoading || isPending || !options['data-client-token']) { return ( - + ); } diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx index fb16f7e602e..056fc1c7e61 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx @@ -33,7 +33,7 @@ const PaymentMethods = ({ justifyContent: 'center', }} > - +
    ); } diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx index 17d32930f5e..a313fe32b60 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx @@ -244,7 +244,7 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { {showCredentials && credentialsLoading ? (
    - +
    ) : credentialsError ? ( <> diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Backups/BackupSelect.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Backups/BackupSelect.tsx index e00f06e22df..11e1ee972cf 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Backups/BackupSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Backups/BackupSelect.tsx @@ -3,8 +3,8 @@ import React from 'react'; import { useController, useWatch } from 'react-hook-form'; import { Box } from 'src/components/Box'; -import { CircularProgress } from 'src/components/CircularProgress'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; +import { LinearProgress } from 'src/components/LinearProgress'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { SelectionCard } from 'src/components/SelectionCard/SelectionCard'; @@ -38,11 +38,7 @@ export const BackupSelect = () => { } if (isFetching) { - return ( - - - - ); + return ; } if (hasNoBackups) { diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx index 59dfc3b7a07..839e13bc267 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { useController, useFormContext } from 'react-hook-form'; import { Box } from 'src/components/Box'; -import { CircularProgress } from 'src/components/CircularProgress'; +import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Stack } from 'src/components/Stack'; import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; @@ -53,7 +53,7 @@ export const AppsList = ({ onOpenDetailsDrawer }: Props) => { justifyContent="center" width="100%" > - +
    ); } diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectAppPanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectAppPanel.tsx index ada6d0d36b7..4c9241c1ed9 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectAppPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectAppPanel.tsx @@ -1,8 +1,8 @@ import { Theme, styled } from '@mui/material/styles'; import * as React from 'react'; +import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; -import { LandingLoading } from 'src/components/LandingLoading/LandingLoading'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { AppPanelSection } from 'src/features/Linodes/LinodesCreate/AppPanelSection'; @@ -74,7 +74,7 @@ class SelectAppPanel extends React.PureComponent { return ( - + ); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx index cea0386e54e..872c66cb5be 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPTransfer.tsx @@ -503,7 +503,7 @@ export const IPTransfer = (props: Props) => { ) : null} {(isLoading || ipv6RangesLoading) && searchText === '' ? (
    - +
    ) : ( <> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx index 9f517a993b5..903580bcbb9 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddressRow.tsx @@ -151,7 +151,7 @@ const RangeRDNSCell = (props: { const ipsWithRDNS = listIPv6InRange(range.range, range.prefix, ipsInRegion); if (ipv6Loading) { - return ; + return ; } // We don't show anything if there are no addresses. diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferContent.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferContent.tsx index 9a6cc88ef49..c0b65afd2e6 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferContent.tsx @@ -75,7 +75,7 @@ export const TransferContent = (props: ContentProps) => { return ( - + ); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx index c79628fb224..8f2a972affa 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx @@ -119,7 +119,7 @@ export const TransferHistory = React.memo((props: Props) => { if (statsLoading) { return ( - + ); } diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx index e1a22966b17..e7eafa6cad4 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx @@ -1,8 +1,9 @@ -import { Box, CircularProgress, Stack } from '@mui/material'; +import { Box, Stack } from '@mui/material'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { Accordion } from 'src/components/Accordion'; +import { CircleProgress } from 'src/components/CircleProgress'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { Notice } from 'src/components/Notice/Notice'; import { Toggle } from 'src/components/Toggle/Toggle'; @@ -58,7 +59,7 @@ export const LinodeWatchdogPanel = (props: Props) => { label={ {linode?.watchdog_enabled ? 'Enabled' : 'Disabled'} - {isLoading && } + {isLoading && } } disabled={isReadOnly} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/StatsPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/StatsPanel.tsx index 0c3b9b3b82b..d6a3576cb9b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/StatsPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/StatsPanel.tsx @@ -21,14 +21,14 @@ export const StatsPanel = (props: Props) => { {loading ? (
    - +
    ) : ( renderBody() diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerConfigurations.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerConfigurations.tsx index 06db352e302..5c8923bb7bf 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerConfigurations.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerConfigurations.tsx @@ -52,7 +52,7 @@ export const LoadBalancerConfigurations = () => { ))} {hasNextPage && fetchNextPage()} />} - {isFetchingNextPage && } + {isFetchingNextPage && } + {region.label} ({region.id}) + ))} @@ -46,8 +42,8 @@ describe('RegionMultiSelect', () => { renderWithTheme( ); @@ -56,11 +52,12 @@ describe('RegionMultiSelect', () => { }); it('should be able to select all the regions correctly', () => { + const onChange = vi.fn(); renderWithTheme( ); @@ -70,26 +67,17 @@ describe('RegionMultiSelect', () => { fireEvent.click(screen.getByRole('option', { name: 'Select All' })); - // Check if all the option is selected - expect( - screen.getByRole('option', { - name: 'Newark, NJ (us-east)', - }) - ).toHaveAttribute('aria-selected', 'true'); - expect( - screen.getByRole('option', { - name: 'Newark, NJ (us-east)', - }) - ).toHaveAttribute('aria-selected', 'true'); + expect(onChange).toHaveBeenCalledWith([regionAtlanta.id, regionNewark.id]); }); it('should be able to deselect all the regions', () => { + const onChange = vi.fn(); renderWithTheme( ); @@ -98,17 +86,7 @@ describe('RegionMultiSelect', () => { fireEvent.click(screen.getByRole('option', { name: 'Deselect All' })); - // Check if all the option is deselected selected - expect( - screen.getByRole('option', { - name: 'Newark, NJ (us-east)', - }) - ).toHaveAttribute('aria-selected', 'false'); - expect( - screen.getByRole('option', { - name: 'Newark, NJ (us-east)', - }) - ).toHaveAttribute('aria-selected', 'false'); + expect(onChange).toHaveBeenCalledWith([]); }); it('should render selected regions correctly', () => { @@ -121,30 +99,34 @@ describe('RegionMultiSelect', () => { /> )} currentCapability="Block Storage" - handleSelection={mockHandleSelection} - regions={[...regionsNewark, ...regionsAtlanta]} - selectedIds={[]} + onChange={mockHandleSelection} + regions={[regionNewark, regionAtlanta]} + selectedIds={[regionNewark.id]} /> ); // Open the dropdown fireEvent.click(screen.getByRole('button', { name: 'Open' })); - fireEvent.click(screen.getByRole('option', { name: 'Select All' })); - - // Close the dropdown - fireEvent.click(screen.getByRole('button', { name: 'Close' })); - - // Check if all the options are rendered + // Check Newark chip shows becaused it is selected expect( screen.getByRole('listitem', { - name: 'Newark, NJ (us-east)', + name: 'Newark, NJ', }) ).toBeInTheDocument(); + + // Newark is selected expect( - screen.getByRole('listitem', { + screen.getByRole('option', { name: 'Newark, NJ (us-east)', }) - ).toBeInTheDocument(); + ).toHaveAttribute('aria-selected', 'true'); + + // Atlanta is not selected + expect( + screen.getByRole('option', { + name: 'Atlanta, GA (us-southeast)', + }) + ).toHaveAttribute('aria-selected', 'false'); }); }); diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx index 2c69a74d0d5..4ba6d5879a7 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -1,13 +1,15 @@ +import { Region } from '@linode/api-v4'; import CloseIcon from '@mui/icons-material/Close'; -import React, { useEffect, useMemo, useState } from 'react'; +import React from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; -import { StyledListItem } from 'src/components/Autocomplete/Autocomplete.styles'; import { Box } from 'src/components/Box'; import { Chip } from 'src/components/Chip'; import { Flag } from 'src/components/Flag'; import { useAllAccountAvailabilitiesQuery } from 'src/queries/account/availability'; +import { getRegionCountryGroup } from 'src/utilities/formatRegion'; +import { StyledListItem } from '../Autocomplete/Autocomplete.styles'; import { RegionOption } from './RegionOption'; import { StyledAutocompleteContainer, @@ -15,19 +17,19 @@ import { } from './RegionSelect.styles'; import { getRegionOptions, - getSelectedRegionsByIds, + isRegionOptionUnavailable, } from './RegionSelect.utils'; import type { + DisableRegionOption, RegionMultiSelectProps, - RegionSelectOption, } from './RegionSelect.types'; interface LabelComponentProps { - selection: RegionSelectOption; + region: Region; } -const SelectedRegion = ({ selection }: LabelComponentProps) => { +const SelectedRegion = ({ region }: LabelComponentProps) => { return ( { transform: 'scale(0.8)', })} > - + - {selection.label} + {region.label} ({region.id}) ); }; @@ -55,10 +57,10 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { currentCapability, disabled, errorText, - handleSelection, helperText, isClearable, label, + onChange, placeholder, regions, required, @@ -72,84 +74,61 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { isLoading: accountAvailabilityLoading, } = useAllAccountAvailabilitiesQuery(); - const [selectedRegions, setSelectedRegions] = useState( - getSelectedRegionsByIds({ - accountAvailabilityData: accountAvailability, - currentCapability, - regions, - selectedRegionIds: selectedIds ?? [], - }) + const regionOptions = getRegionOptions({ currentCapability, regions }); + + const selectedRegions = regionOptions.filter((r) => + selectedIds.includes(r.id) ); - const handleRegionChange = (selection: RegionSelectOption[]) => { - setSelectedRegions(selection); + const handleRemoveOption = (regionToRemove: string) => { + onChange(selectedIds.filter((value) => value !== regionToRemove)); }; - useEffect(() => { - setSelectedRegions( - getSelectedRegionsByIds({ + const disabledRegions = regionOptions.reduce< + Record + >((acc, region) => { + if ( + isRegionOptionUnavailable({ accountAvailabilityData: accountAvailability, currentCapability, - regions, - selectedRegionIds: selectedIds ?? [], + region, }) - ); - }, [selectedIds, accountAvailability, currentCapability, regions]); - - const options = useMemo( - () => - getRegionOptions({ - accountAvailabilityData: accountAvailability, - currentCapability, - regions, - }), - [accountAvailability, currentCapability, regions] - ); - - const handleRemoveOption = (regionToRemove: string) => { - const updatedSelectedOptions = selectedRegions.filter( - (option) => option.value !== regionToRemove - ); - const updatedSelectedIds = updatedSelectedOptions.map( - (region) => region.value - ); - setSelectedRegions(updatedSelectedOptions); - handleSelection(updatedSelectedIds); - }; + ) { + acc[region.id] = { + reason: + 'This region is currently unavailable. For help, open a support ticket.', + }; + } + return acc; + }, {}); return ( <> - Boolean(option.disabledProps?.disabled) - } - groupBy={(option: RegionSelectOption) => { - return option?.data?.region; + groupBy={(option) => { + if (!option.site_type) { + // Render empty group for "Select All / Deselect All" + return ''; + } + return getRegionCountryGroup(option); }} - isOptionEqualToValue={( - option: RegionSelectOption, - value: RegionSelectOption - ) => option.value === value.value} - onChange={(_, selectedOption) => - handleRegionChange(selectedOption as RegionSelectOption[]) + onChange={(_, selectedOptions) => + onChange(selectedOptions.map((region) => region.id)) } - onClose={() => { - const selectedIds = selectedRegions.map((region) => region.value); - handleSelection(selectedIds); - }} renderOption={(props, option, { selected }) => { - if (!option.data) { - // Render options like "Select All / Deselect All " + if (!option.site_type) { + // Render options like "Select All / Deselect All" return {option.label}; } // Render regular options return ( ); @@ -158,11 +137,11 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { return tagValue.map((option, index) => ( } key={index} - label={} - onDelete={() => handleRemoveOption(option.value)} + label={} + onDelete={() => handleRemoveOption(option.id)} /> )); }} @@ -184,11 +163,12 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { disableClearable={!isClearable} disabled={disabled} errorText={errorText} + getOptionDisabled={(option) => Boolean(disabledRegions[option.id])} label={label ?? 'Regions'} loading={accountAvailabilityLoading} multiple noOptionsText="No results" - options={options} + options={regionOptions} placeholder={placeholder ?? 'Select Regions'} value={selectedRegions} /> diff --git a/packages/manager/src/components/RegionSelect/RegionOption.tsx b/packages/manager/src/components/RegionSelect/RegionOption.tsx index 33c4a1b36f9..d5d87778acd 100644 --- a/packages/manager/src/components/RegionSelect/RegionOption.tsx +++ b/packages/manager/src/components/RegionSelect/RegionOption.tsx @@ -13,34 +13,37 @@ import { StyledListItem, sxDistributedRegionIcon, } from './RegionSelect.styles'; -import { RegionSelectOption } from './RegionSelect.types'; +import type { DisableRegionOption } from './RegionSelect.types'; +import type { Region } from '@linode/api-v4'; import type { ListItemComponentsPropsOverrides } from '@mui/material/ListItem'; -type Props = { - displayDistributedRegionIcon?: boolean; - option: RegionSelectOption; +interface Props { + disabledOptions?: DisableRegionOption; props: React.HTMLAttributes; + region: Region; selected?: boolean; -}; +} export const RegionOption = ({ - displayDistributedRegionIcon, - option, + disabledOptions, props, + region, selected, }: Props) => { const { className, onClick } = props; - const { data, disabledProps, label, value } = option; - const isRegionDisabled = Boolean(disabledProps?.disabled); - const isRegionDisabledReason = disabledProps?.reason; + const isRegionDisabled = Boolean(disabledOptions); + const isRegionDisabledReason = disabledOptions?.reason; + + const displayDistributedRegionIcon = + region.site_type === 'edge' || region.site_type === 'distributed'; return ( @@ -72,9 +74,9 @@ export const RegionOption = ({ <> - + - {label} + {region.label} ({region.id}) {displayDistributedRegionIcon && (  (This region is a distributed region.) @@ -84,7 +86,7 @@ export const RegionOption = ({ {isRegionDisabledReason} )} - {selected && } + {selected && } {displayDistributedRegionIcon && ( } diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.stories.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.stories.tsx index fc7b5707a6d..45e9aa8a17d 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.stories.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.stories.tsx @@ -1,3 +1,4 @@ +import { useArgs } from '@storybook/preview-api'; import React from 'react'; import { regions } from 'src/__data__/regionsData'; @@ -11,14 +12,12 @@ import type { Meta, StoryObj } from '@storybook/react'; export const Default: StoryObj = { render: (args) => { const SelectWrapper = () => { - const [open, setOpen] = React.useState(false); - + const [_, updateArgs] = useArgs(); return ( setOpen(false)} - open={open} + onChange={(e, region) => updateArgs({ value: region?.id })} /> ); @@ -34,11 +33,10 @@ const meta: Meta = { disabled: false, errorText: '', helperText: '', - isClearable: false, label: 'Region', regions, required: true, - selectedId: regions[2].id, + value: regions[2].id, }, component: RegionSelect, title: 'Components/Selects/Region Select', diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx index 13fe9614d57..bd97e2a64a1 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.test.tsx @@ -15,13 +15,12 @@ describe('RegionSelect', () => { currentCapability: 'Linodes', disabled: false, errorText: '', - handleSelection: vi.fn(), + onChange: vi.fn(), helperText: '', - isClearable: false, label: '', regions, required: false, - selectedId: '', + value: '', tooltipText: '', width: 100, }; diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 745857911a1..6c1fbef0d0c 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -7,6 +7,7 @@ import { Flag } from 'src/components/Flag'; import { Link } from 'src/components/Link'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { useAllAccountAvailabilitiesQuery } from 'src/queries/account/availability'; +import { getRegionCountryGroup } from 'src/utilities/formatRegion'; import { RegionOption } from './RegionOption'; import { @@ -15,12 +16,16 @@ import { StyledFlagContainer, sxDistributedRegionIcon, } from './RegionSelect.styles'; -import { getRegionOptions, getSelectedRegionById } from './RegionSelect.utils'; +import { + getRegionOptions, + isRegionOptionUnavailable, +} from './RegionSelect.utils'; import type { - RegionSelectOption, + DisableRegionOption, RegionSelectProps, } from './RegionSelect.types'; +import type { Region } from '@linode/api-v4'; /** * A specific select for regions. @@ -31,22 +36,26 @@ import type { * * We do not display the selected check mark for single selects. */ -export const RegionSelect = React.memo((props: RegionSelectProps) => { +export const RegionSelect = < + DisableClearable extends boolean | undefined = undefined +>( + props: RegionSelectProps +) => { const { currentCapability, + disableClearable, disabled, + disabledRegions: disabledRegionsFromProps, errorText, - handleDisabledRegion, - handleSelection, helperText, - isClearable, label, + onChange, regionFilter, regions, required, - selectedId, showDistributedRegionIconHelperText, tooltipText, + value, width, } = props; @@ -55,77 +64,46 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { isLoading: accountAvailabilityLoading, } = useAllAccountAvailabilitiesQuery(); - const regionFromSelectedId: RegionSelectOption | null = - getSelectedRegionById({ - accountAvailabilityData: accountAvailability, - currentCapability, - regions, - selectedRegionId: selectedId ?? '', - }) ?? null; - - const [selectedRegion, setSelectedRegion] = React.useState< - RegionSelectOption | null | undefined - >(regionFromSelectedId); + const regionOptions = getRegionOptions({ + currentCapability, + regionFilter, + regions, + }); - const handleRegionChange = (selection: RegionSelectOption | null) => { - setSelectedRegion(selection); - handleSelection(selection?.value || ''); - }; + const selectedRegion = regionOptions.find((r) => r.id === value) ?? null; - React.useEffect(() => { - if (selectedId) { - setSelectedRegion(regionFromSelectedId); - } else { - // We need to reset the state when create types change - setSelectedRegion(null); + const disabledRegions = regionOptions.reduce< + Record + >((acc, region) => { + if (disabledRegionsFromProps?.[region.id]) { + acc[region.id] = disabledRegionsFromProps[region.id]; } - }, [selectedId, regions]); - - const options = React.useMemo( - () => - getRegionOptions({ + if ( + isRegionOptionUnavailable({ accountAvailabilityData: accountAvailability, currentCapability, - handleDisabledRegion, - regionFilter, - regions, - }), - [ - accountAvailability, - currentCapability, - handleDisabledRegion, - regions, - regionFilter, - ] - ); + region, + }) + ) { + acc[region.id] = { + reason: + 'This region is currently unavailable. For help, open a support ticket.', + }; + } + return acc; + }, {}); return ( - - Boolean(option.disabledProps?.disabled) - } - isOptionEqualToValue={( - option: RegionSelectOption, - { value }: RegionSelectOption - ) => option.value === value} - onChange={(_, selectedOption: RegionSelectOption) => { - handleRegionChange(selectedOption); - }} - renderOption={(props, option) => { - return ( - - ); - }} + + renderOption={(props, region) => ( + + )} sx={(theme) => ({ [theme.breakpoints.up('md')]: { width: '416px', @@ -134,20 +112,19 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { textFieldProps={{ ...props.textFieldProps, InputProps: { - endAdornment: regionFilter !== 'core' && - (selectedRegion?.site_type === 'distributed' || - selectedRegion?.site_type === 'edge') && ( - } - status="other" - sxTooltipIcon={sxDistributedRegionIcon} - text="This region is a distributed region." - /> - ), + endAdornment: (selectedRegion?.site_type === 'distributed' || + selectedRegion?.site_type === 'edge') && ( + } + status="other" + sxTooltipIcon={sxDistributedRegionIcon} + text="This region is a distributed region." + /> + ), required, startAdornment: selectedRegion && ( - + ), }, @@ -156,18 +133,21 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { autoHighlight clearOnBlur data-testid="region-select" - disableClearable={!isClearable} + disableClearable={disableClearable} disabled={disabled} errorText={errorText} - groupBy={(option: RegionSelectOption) => option.data.region} + getOptionDisabled={(option) => Boolean(disabledRegions[option.id])} + getOptionLabel={(region) => `${region.label} (${region.id})`} + groupBy={(option) => getRegionCountryGroup(option)} helperText={helperText} label={label ?? 'Region'} loading={accountAvailabilityLoading} loadingText="Loading regions..." noOptionsText="No results" - options={options} + onChange={onChange} + options={regionOptions} placeholder="Select a Region" - value={selectedRegion} + value={selectedRegion as Region} /> {showDistributedRegionIconHelperText && ( // @TODO Gecko Beta: Add docs link @@ -190,4 +170,4 @@ export const RegionSelect = React.memo((props: RegionSelectProps) => { )} ); -}); +}; diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index e7a37e6c9dd..d2dfbe23f96 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -3,31 +3,29 @@ import React from 'react'; import type { AccountAvailability, Capabilities, - Country, Region, RegionSite, } from '@linode/api-v4'; import type { EnhancedAutocompleteProps } from 'src/components/Autocomplete/Autocomplete'; -export interface RegionSelectOption { - data: { - country: Country; - region: string; - }; - disabledProps?: { - disabled: boolean; - reason?: JSX.Element | string; - tooltipWidth?: number; - }; - label: string; - site_type: RegionSite; - value: string; +export interface DisableRegionOption { + /** + * The reason the region option is disabled. + * This is shown to the user as a tooltip. + */ + reason: JSX.Element | string; + /** + * An optional minWith applied to the tooltip + * @default 215 + */ + tooltipWidth?: number; } -export interface RegionSelectProps - extends Omit< - EnhancedAutocompleteProps, - 'label' | 'onChange' | 'options' +export interface RegionSelectProps< + DisableClearable extends boolean | undefined = undefined +> extends Omit< + EnhancedAutocompleteProps, + 'label' | 'options' | 'value' > { /** * The specified capability to filter the regions on. Any region that does not have the `currentCapability` will not appear in the RegionSelect dropdown. @@ -37,71 +35,50 @@ export interface RegionSelectProps * See `ImageUpload.tsx` for an example of a RegionSelect with an undefined `currentCapability` - there is no capability associated with Images yet. */ currentCapability: Capabilities | undefined; - handleDisabledRegion?: ( - region: Region - ) => RegionSelectOption['disabledProps']; - handleSelection: (id: string) => void; + /** + * A key/value object for disabling regions by their ID. + */ + disabledRegions?: Record; helperText?: string; - isClearable?: boolean; label?: string; regionFilter?: RegionSite; regions: Region[]; required?: boolean; - selectedId: null | string; showDistributedRegionIconHelperText?: boolean; tooltipText?: string; + /** + * The ID of the selected region. + */ + value: null | string; width?: number; } export interface RegionMultiSelectProps extends Omit< - EnhancedAutocompleteProps, + EnhancedAutocompleteProps, 'label' | 'onChange' | 'options' > { SelectedRegionsList?: React.ComponentType<{ onRemove: (region: string) => void; - selectedRegions: RegionSelectOption[]; + selectedRegions: Region[]; }>; currentCapability: Capabilities | undefined; - handleSelection: (ids: string[]) => void; helperText?: string; isClearable?: boolean; label?: string; + onChange: (ids: string[]) => void; regions: Region[]; required?: boolean; selectedIds: string[]; - sortRegionOptions?: (a: RegionSelectOption, b: RegionSelectOption) => number; + sortRegionOptions?: (a: Region, b: Region) => number; tooltipText?: string; width?: number; } -export interface RegionOptionAvailability { +export interface GetRegionOptionAvailability { accountAvailabilityData: AccountAvailability[] | undefined; currentCapability: Capabilities | undefined; - handleDisabledRegion?: ( - region: Region - ) => RegionSelectOption['disabledProps']; -} - -export interface GetRegionOptions extends RegionOptionAvailability { - regionFilter?: RegionSite; - regions: Region[]; -} - -export interface GetSelectedRegionById extends RegionOptionAvailability { - regions: Region[]; - selectedRegionId: string; -} - -export interface GetRegionOptionAvailability extends RegionOptionAvailability { region: Region; } -export interface GetSelectedRegionsByIdsArgs { - accountAvailabilityData: AccountAvailability[] | undefined; - currentCapability: Capabilities | undefined; - regions: Region[]; - selectedRegionIds: string[]; -} - export type SupportedDistributedRegionTypes = 'Distributions' | 'StackScripts'; diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx index d7c05901120..22ba45e2dc8 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.test.tsx @@ -2,271 +2,190 @@ import { accountAvailabilityFactory, regionFactory } from 'src/factories'; import { getRegionOptions, - getSelectedRegionById, - getSelectedRegionsByIds, isRegionOptionUnavailable, } from './RegionSelect.utils'; -import type { RegionSelectOption } from './RegionSelect.types'; -import type { Region } from '@linode/api-v4'; - -const accountAvailabilityData = [ - accountAvailabilityFactory.build({ - region: 'ap-south', - unavailable: ['Linodes'], - }), -]; - -const regions: Region[] = [ - regionFactory.build({ - capabilities: ['Linodes'], - country: 'us', - id: 'us-1', - label: 'US Location', - }), - regionFactory.build({ - capabilities: ['Linodes'], - country: 'ca', - id: 'ca-1', - label: 'CA Location', - }), - regionFactory.build({ - capabilities: ['Linodes'], - country: 'jp', - id: 'jp-1', - label: 'JP Location', - }), -]; - -const distributedRegions = [ - ...regions, - regionFactory.build({ - capabilities: ['Linodes'], - country: 'us', - id: 'us-den-10', - label: 'Gecko Distributed Region Test', - site_type: 'distributed', - }), - regionFactory.build({ - capabilities: ['Linodes'], - country: 'us', - id: 'us-den-11', - label: 'Gecko Distributed Region Test 2', - site_type: 'distributed', - }), -]; - -const expectedRegions: RegionSelectOption[] = [ - { - data: { - country: 'us', - region: 'North America', - }, - disabledProps: { - disabled: false, - }, - label: 'US Location (us-1)', - site_type: 'core', - value: 'us-1', - }, - { - data: { country: 'ca', region: 'North America' }, - disabledProps: { - disabled: false, - }, - label: 'CA Location (ca-1)', - site_type: 'core', - value: 'ca-1', - }, - { - data: { country: 'jp', region: 'Asia' }, - disabledProps: { - disabled: false, - }, - label: 'JP Location (jp-1)', - site_type: 'core', - value: 'jp-1', - }, -]; - -const expectedDistributedRegions = [ - { - data: { country: 'us', region: 'North America' }, - disabledProps: { - disabled: false, - }, - label: 'Gecko Distributed Region Test (us-den-10)', - site_type: 'distributed', - value: 'us-den-10', - }, - { - data: { country: 'us', region: 'North America' }, - disabledProps: { - disabled: false, - }, - label: 'Gecko Distributed Region Test 2 (us-den-11)', - site_type: 'distributed', - value: 'us-den-11', - }, -]; - describe('getRegionOptions', () => { it('should return an empty array if no regions are provided', () => { - const regions: Region[] = []; const result = getRegionOptions({ - accountAvailabilityData, currentCapability: 'Linodes', - regions, + regions: [], }); expect(result).toEqual([]); }); - it('should return a sorted array of OptionType objects with North America first', () => { - const result: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, + it('should return a sorted array of regions with North America first', () => { + const regions = [ + regionFactory.build({ + capabilities: ['Linodes'], + country: 'jp', + id: 'jp-1', + label: 'JP Location', + }), + regionFactory.build({ + capabilities: ['Linodes'], + country: 'us', + id: 'us-1', + label: 'US Location', + }), + regionFactory.build({ + capabilities: ['Linodes'], + country: 'ca', + id: 'ca-1', + label: 'CA Location', + }), + ]; + + const result = getRegionOptions({ currentCapability: 'Linodes', regions, }); - expect(result).toEqual(expectedRegions); + expect(result).toEqual([ + regionFactory.build({ + capabilities: ['Linodes'], + country: 'us', + id: 'us-1', + label: 'US Location', + }), + regionFactory.build({ + capabilities: ['Linodes'], + country: 'ca', + id: 'ca-1', + label: 'CA Location', + }), + regionFactory.build({ + capabilities: ['Linodes'], + country: 'jp', + id: 'jp-1', + label: 'JP Location', + }), + ]); }); it('should filter out regions that do not have the currentCapability if currentCapability is provided', () => { - const regionsToFilter: Region[] = [ - ...regions, + const distributedRegions = [ regionFactory.build({ - capabilities: ['Object Storage'], - country: 'pe', - id: 'peru-1', - label: 'Peru Location', + capabilities: ['Linodes'], + country: 'us', + id: 'us-den-10', + label: 'Gecko Distributed Region Test', + site_type: 'distributed', + }), + regionFactory.build({ + capabilities: [], + country: 'us', + id: 'us-den-11', + label: 'Gecko Distributed Region Test 2', + site_type: 'distributed', }), ]; - const result: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, - currentCapability: 'Linodes', - regions: regionsToFilter, - }); - - expect(result).toEqual(expectedRegions); - }); - - it('should filter out distributed regions if regionFilter is core', () => { - const result: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, - currentCapability: 'Linodes', - regionFilter: 'core', - regions: distributedRegions, - }); - - expect(result).toEqual(expectedRegions); - }); - - it('should filter out core regions if regionFilter is "distributed"', () => { - const result: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, - currentCapability: 'Linodes', - regionFilter: 'distributed', - regions: distributedRegions, - }); - - expect(result).toEqual(expectedDistributedRegions); - }); - - it('should not filter out any regions if regionFilter is undefined', () => { - const regions = [...expectedDistributedRegions, ...expectedRegions]; - - const result: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, + const result = getRegionOptions({ currentCapability: 'Linodes', - regionFilter: undefined, regions: distributedRegions, }); - expect(result).toEqual(regions); - }); - - it('should have its option disabled if the region is unavailable', () => { - const _regions = [ - ...regions, + expect(result).toEqual([ regionFactory.build({ capabilities: ['Linodes'], country: 'us', - id: 'ap-south', - label: 'US Location 2', + id: 'us-den-10', + label: 'Gecko Distributed Region Test', + site_type: 'distributed', }), - ]; - - const result: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, - currentCapability: 'Linodes', - regions: _regions, - }); - - const unavailableRegion = result.find( - (region) => region.value === 'ap-south' - ); - - expect(unavailableRegion?.disabledProps?.disabled).toBe(true); + ]); }); - it('should have its option disabled if `handleDisabledRegion` is passed', () => { - const result: RegionSelectOption[] = getRegionOptions({ - accountAvailabilityData, - currentCapability: 'Linodes', - handleDisabledRegion: (region) => ({ - ...region, - disabled: true, + it('should filter out distributed regions if regionFilter is core', () => { + const regions = [ + regionFactory.build({ + id: 'us-1', + label: 'US Site 1', + site_type: 'distributed', }), + regionFactory.build({ + id: 'us-2', + label: 'US Site 2', + site_type: 'core', + }), + ]; + + const result = getRegionOptions({ + currentCapability: undefined, + regionFilter: 'core', regions, }); - const unavailableRegion = result.find((region) => region.value === 'us-1'); - - expect(unavailableRegion?.disabledProps?.disabled).toBe(true); + expect(result).toEqual([ + regionFactory.build({ + id: 'us-2', + label: 'US Site 2', + site_type: 'core', + }), + ]); }); -}); -describe('getSelectedRegionById', () => { - it('should return the correct OptionType for a selected region', () => { - const selectedRegionId = 'us-1'; + it('should filter out core regions if regionFilter is "distributed"', () => { + const regions = [ + regionFactory.build({ + id: 'us-1', + label: 'US Site 1', + site_type: 'distributed', + }), + regionFactory.build({ + id: 'us-2', + label: 'US Site 2', + site_type: 'core', + }), + ]; - const result = getSelectedRegionById({ - accountAvailabilityData, - currentCapability: 'Linodes', + const result = getRegionOptions({ + currentCapability: undefined, + regionFilter: 'distributed', regions, - selectedRegionId, }); - // Expected result - const expected = { - data: { - country: 'us', - region: 'North America', - }, - label: 'US Location (us-1)', - site_type: 'core', - value: 'us-1', - }; - - expect(result).toEqual(expected); + expect(result).toEqual([ + regionFactory.build({ + id: 'us-1', + label: 'US Site 1', + site_type: 'distributed', + }), + ]); }); - it('should return undefined for an unknown region', () => { - const selectedRegionId = 'unknown'; - - const result = getSelectedRegionById({ - accountAvailabilityData, - currentCapability: 'Linodes', + it('should not filter out any regions if regionFilter is undefined', () => { + const regions = [ + regionFactory.build({ + id: 'us-1', + label: 'US Site 1', + site_type: 'distributed', + }), + regionFactory.build({ + id: 'us-2', + label: 'US Site 2', + site_type: 'core', + }), + ]; + const result = getRegionOptions({ + currentCapability: undefined, + regionFilter: undefined, regions, - selectedRegionId, }); - expect(result).toBeUndefined(); + expect(result).toEqual(regions); }); }); +const accountAvailabilityData = [ + accountAvailabilityFactory.build({ + region: 'ap-south', + unavailable: ['Linodes'], + }), +]; + describe('getRegionOptionAvailability', () => { it('should return true if the region is not available', () => { const result = isRegionOptionUnavailable({ @@ -292,64 +211,3 @@ describe('getRegionOptionAvailability', () => { expect(result).toBe(false); }); }); - -describe('getSelectedRegionsByIds', () => { - it('should return an array of RegionSelectOptions for the given selectedRegionIds', () => { - const selectedRegionIds = ['us-1', 'ca-1']; - - const result = getSelectedRegionsByIds({ - accountAvailabilityData, - currentCapability: 'Linodes', - regions, - selectedRegionIds, - }); - - const expected = [ - { - data: { - country: 'us', - region: 'North America', - }, - label: 'US Location (us-1)', - site_type: 'core', - value: 'us-1', - }, - { - data: { - country: 'ca', - region: 'North America', - }, - label: 'CA Location (ca-1)', - site_type: 'core', - value: 'ca-1', - }, - ]; - - expect(result).toEqual(expected); - }); - - it('should exclude regions that are not found in the regions array', () => { - const selectedRegionIds = ['us-1', 'non-existent-region']; - - const result = getSelectedRegionsByIds({ - accountAvailabilityData, - currentCapability: 'Linodes', - regions, - selectedRegionIds, - }); - - const expected = [ - { - data: { - country: 'us', - region: 'North America', - }, - label: 'US Location (us-1)', - site_type: 'core', - value: 'us-1', - }, - ]; - - expect(result).toEqual(expected); - }); -}); diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx index 41c782d9e57..1c417cb7d6d 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx @@ -1,16 +1,13 @@ -import { CONTINENT_CODE_TO_CONTINENT } from '@linode/api-v4'; - import { - getRegionCountryGroup, - getSelectedRegion, -} from 'src/utilities/formatRegion'; + CONTINENT_CODE_TO_CONTINENT, + Capabilities, + RegionSite, +} from '@linode/api-v4'; + +import { getRegionCountryGroup } from 'src/utilities/formatRegion'; import type { GetRegionOptionAvailability, - GetRegionOptions, - GetSelectedRegionById, - GetSelectedRegionsByIdsArgs, - RegionSelectOption, SupportedDistributedRegionTypes, } from './RegionSelect.types'; import type { AccountAvailability, Region } from '@linode/api-v4'; @@ -18,97 +15,58 @@ import type { LinodeCreateType } from 'src/features/Linodes/LinodesCreate/types' const NORTH_AMERICA = CONTINENT_CODE_TO_CONTINENT.NA; -/** - * Returns an array of OptionType objects for use in the RegionSelect component. - * Handles the disabled state of each region based on the user's account availability or an optional custom handler. - * Regions are sorted alphabetically by region, with North America first. - * - * @returns An array of RegionSelectOption objects - */ +interface RegionSelectOptionsOptions { + currentCapability: Capabilities | undefined; + regionFilter?: RegionSite; + regions: Region[]; +} + export const getRegionOptions = ({ - accountAvailabilityData, currentCapability, - handleDisabledRegion, regionFilter, regions, -}: GetRegionOptions): RegionSelectOption[] => { - const filteredRegionsByCapability = currentCapability - ? regions.filter((region) => - region.capabilities.includes(currentCapability) - ) - : regions; - - const filteredRegionsByCapabilityAndSiteType = regionFilter - ? filteredRegionsByCapability.filter( - (region) => region.site_type === regionFilter - ) - : filteredRegionsByCapability; - - const isRegionUnavailable = (region: Region) => - isRegionOptionUnavailable({ - accountAvailabilityData, - currentCapability, - region, - }); - - return filteredRegionsByCapabilityAndSiteType - .map((region: Region) => { - const group = getRegionCountryGroup(region); - - // The region availability is the first check we run, regardless of the handleDisabledRegion function. - // This check always runs, and if the region is unavailable, the region will be disabled. - const disabledProps = isRegionUnavailable(region) - ? { - disabled: true, - reason: - 'This region is currently unavailable. For help, open a support ticket.', - tooltipWidth: 250, - } - : handleDisabledRegion?.(region)?.disabled - ? handleDisabledRegion(region) - : { - disabled: false, - }; - - return { - data: { - country: region.country, - region: group, - }, - disabledProps, - label: `${region.label} (${region.id})`, - site_type: region.site_type, - value: region.id, - }; +}: RegionSelectOptionsOptions) => { + return regions + .filter((region) => { + if ( + currentCapability && + !region.capabilities.includes(currentCapability) + ) { + return false; + } + if (regionFilter && region.site_type !== regionFilter) { + return false; + } + return true; }) .sort((region1, region2) => { + const region1Group = getRegionCountryGroup(region1); + const region2Group = getRegionCountryGroup(region2); + // North America group comes first if ( - region1.data.region === NORTH_AMERICA && - region2.data.region !== NORTH_AMERICA + region1Group === 'North America' && + region2Group !== 'North America' ) { return -1; } - if ( - region1.data.region !== NORTH_AMERICA && - region2.data.region === NORTH_AMERICA - ) { + if (region1Group !== NORTH_AMERICA && region2Group === NORTH_AMERICA) { return 1; } // Rest of the regions are sorted alphabetically - if (region1.data.region < region2.data.region) { + if (region1Group < region2Group) { return -1; } - if (region1.data.region > region2.data.region) { + if (region1Group > region2Group) { return 1; } // Then we group by country - if (region1.data.country < region2.data.country) { + if (region1.country < region2.country) { return 1; } - if (region1.data.country > region2.data.country) { + if (region1.country > region2.country) { return -1; } @@ -121,34 +79,6 @@ export const getRegionOptions = ({ }); }; -/** - * Util to map a region ID to an OptionType object. - * - * @returns an RegionSelectOption object for the currently selected region. - */ -export const getSelectedRegionById = ({ - regions, - selectedRegionId, -}: GetSelectedRegionById): RegionSelectOption | undefined => { - const selectedRegion = getSelectedRegion(regions, selectedRegionId); - - if (!selectedRegion) { - return undefined; - } - - const group = getRegionCountryGroup(selectedRegion); - - return { - data: { - country: selectedRegion?.country, - region: group, - }, - label: `${selectedRegion.label} (${selectedRegion.id})`, - site_type: selectedRegion.site_type, - value: selectedRegion.id, - }; -}; - /** * Util to determine if a region is available to the user for a given capability. * @@ -177,29 +107,6 @@ export const isRegionOptionUnavailable = ({ return regionWithUnavailability.unavailable.includes(currentCapability); }; -/** - * This utility function takes an array of region IDs and returns an array of corresponding RegionSelectOption objects. - * - * @returns An array of RegionSelectOption objects corresponding to the selected region IDs. - */ -export const getSelectedRegionsByIds = ({ - accountAvailabilityData, - currentCapability, - regions, - selectedRegionIds, -}: GetSelectedRegionsByIdsArgs): RegionSelectOption[] => { - return selectedRegionIds - .map((selectedRegionId) => - getSelectedRegionById({ - accountAvailabilityData, - currentCapability, - regions, - selectedRegionId, - }) - ) - .filter((region): region is RegionSelectOption => !!region); -}; - /** * Util to determine whether a create type has support for distributed regions. * diff --git a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx index db01e6a6f9f..34e14dc58a6 100644 --- a/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx +++ b/packages/manager/src/components/RemovableSelectionsList/RemovableSelectionsList.tsx @@ -22,7 +22,7 @@ export type RemovableItem = { // as 'any' because we do not know what types they could be. // Trying to type them as 'unknown' led to type errors. [key: string]: any; - id: number; + id: number | string; label: string; }; @@ -117,9 +117,9 @@ export const RemovableSelectionsList = ( // used to determine when to display a box-shadow to indicate scrollability const listRef = React.useRef(null); const [listHeight, setListHeight] = React.useState(0); - const [removingItemId, setRemovingItemId] = React.useState( - null - ); + const [removingItemId, setRemovingItemId] = React.useState< + null | number | string + >(null); const [isRemoving, setIsRemoving] = React.useState(false); React.useEffect(() => { diff --git a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx index 1fbe47fa423..3d230d03b77 100644 --- a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx +++ b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx @@ -30,7 +30,7 @@ import { RegionSelectProps } from '../RegionSelect/RegionSelect.types'; import type { LinodeCreateType } from 'src/features/Linodes/LinodesCreate/types'; interface SelectRegionPanelProps { - RegionSelectProps?: Partial; + RegionSelectProps?: Partial>; currentCapability: Capabilities; disabled?: boolean; error?: string; @@ -147,17 +147,18 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { ) : null} handleSelection(region.id)} regionFilter={hideDistributedRegions ? 'core' : undefined} regions={regions ?? []} - selectedId={selectedId || null} - showDistributedRegionIconHelperText={ - showDistributedRegionIconHelperText - } + value={selectedId || null} {...RegionSelectProps} /> {showClonePriceWarning && ( diff --git a/packages/manager/src/features/CloudPulse/shared/RegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/RegionSelect.tsx index b60ba23564f..176f4bc37a8 100644 --- a/packages/manager/src/features/CloudPulse/shared/RegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/RegionSelect.tsx @@ -20,16 +20,14 @@ export const CloudPulseRegionSelect = React.memo( return ( { - setRegion(value); - }} currentCapability={undefined} + disableClearable fullWidth - isClearable={false} label="" noMarginTop + onChange={(e, region) => setRegion(region.id)} regions={regions ? regions : []} - selectedId={null} + value={null} /> ); } diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx index 15b7feec0b2..36a598f3b4e 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -496,13 +496,12 @@ const DatabaseCreate = () => { - setFieldValue('region', selected) - } currentCapability="Managed Databases" + disableClearable errorText={errors.region} + onChange={(e, region) => setFieldValue('region', region.id)} regions={regionsData} - selectedId={values.region} + value={values.region} /> diff --git a/packages/manager/src/features/Images/ImageUpload.tsx b/packages/manager/src/features/Images/ImageUpload.tsx index 16f1a318504..0d64ef0b88c 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImageUpload.tsx @@ -256,13 +256,14 @@ export const ImageUpload = () => { onBlur: field.onBlur, }} currentCapability={undefined} + disableClearable errorText={fieldState.error?.message} - handleSelection={field.onChange} helperText="For fastest initial upload, select the region that is geographically closest to you. Once uploaded, you will be able to deploy the image to other regions." label="Region" + onChange={(e, region) => field.onChange(region.id)} regionFilter="core" // Images service will not be supported for Gecko Beta regions={regions ?? []} - selectedId={field.value ?? null} + value={field.value ?? null} /> )} control={form.control} diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index aaeb389f057..7c38f687f8e 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -220,17 +220,16 @@ export const CreateCluster = () => { - setSelectedRegionID(regionID) - } textFieldProps={{ helperText: , helperTextPosition: 'top', }} currentCapability="Kubernetes" + disableClearable errorText={errorMap.region} + onChange={(e, region) => setSelectedRegionID(region.id)} regions={regionsData} - selectedId={selectedId} + value={selectedId} /> diff --git a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx index a8f091afc80..b10784efb62 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx @@ -190,10 +190,11 @@ export const ConfigureForm = React.memo((props: Props) => { helperText, }} currentCapability="Linodes" + disableClearable errorText={errorText} - handleSelection={handleSelectRegion} label="New Region" - selectedId={selectedRegion} + onChange={(e, region) => handleSelectRegion(region.id)} + value={selectedRegion} /> {shouldDisplayPriceComparison && selectedRegion && ( { +const sortRegionOptions = (a: Region, b: Region) => { return sortByString(a.label, b.label, 'asc'); }; @@ -29,9 +29,7 @@ export const AccessKeyRegions = (props: Props) => { return ( { - onChange(ids); - }} + onChange={onChange} currentCapability="Object Storage" disabled={disabled} errorText={errorText} diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList.tsx index 197dc7e5122..50ff7ccd614 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyRegions/SelectedRegionsList.tsx @@ -8,11 +8,11 @@ import { RemovableSelectionsList, } from 'src/components/RemovableSelectionsList/RemovableSelectionsList'; -import type { RegionSelectOption } from 'src/components/RegionSelect/RegionSelect.types'; +import type { Region } from '@linode/api-v4'; interface SelectedRegionsProps { onRemove: (region: string) => void; - selectedRegions: RegionSelectOption[]; + selectedRegions: Region[]; } interface LabelComponentProps { @@ -29,9 +29,9 @@ const SelectedRegion = ({ selection }: LabelComponentProps) => { }} > - + - {selection.label} + {selection.label} ({selection.id}) ); }; @@ -41,14 +41,12 @@ export const SelectedRegionsList = ({ selectedRegions, }: SelectedRegionsProps) => { const handleRemove = (item: RemovableItem) => { - onRemove(item.value); + onRemove(item.id as string); }; return ( { - return { ...item, id: index }; - })} + selectionData={selectedRegions} LabelComponent={SelectedRegion} headerText="" noDataText="" diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx index 93309cfba17..a4fbb9b9152 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx @@ -23,16 +23,16 @@ export const BucketRegions = (props: Props) => { return ( onChange(id)} - isClearable={false} label="Region" onBlur={onBlur} + onChange={(e, region) => onChange(region.id)} placeholder="Select a Region" regions={regions ?? []} required={required} - selectedId={selectedRegion} + value={selectedRegion} /> ); }; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx index 5de99f11411..21d431ff761 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/ClusterSelect.tsx @@ -46,16 +46,16 @@ export const ClusterSelect: React.FC = (props) => { onChange(id)} - isClearable={false} label="Region" onBlur={onBlur} + onChange={(e, region) => onChange(region.id)} placeholder="Select a Region" regions={regionOptions ?? []} required={required} - selectedId={selectedCluster} + value={selectedCluster} /> ); }; diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx index ab4e75fcf48..cfaef030495 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsCreateDrawer.tsx @@ -34,6 +34,7 @@ import { import type { PlacementGroupsCreateDrawerProps } from './types'; import type { CreatePlacementGroupPayload, Region } from '@linode/api-v4'; import type { FormikHelpers } from 'formik'; +import type { DisableRegionOption } from 'src/components/RegionSelect/RegionSelect.types'; export const PlacementGroupsCreateDrawer = ( props: PlacementGroupsCreateDrawerProps @@ -136,6 +137,34 @@ export const PlacementGroupsCreateDrawer = ( selectedRegion )}`; + const disabledRegions = regions?.reduce>( + (acc, region) => { + const isRegionAtCapacity = hasRegionReachedPlacementGroupCapacity({ + allPlacementGroups: allPlacementGroupsInRegion, + region, + }); + if (isRegionAtCapacity) { + acc[region.id] = { + reason: ( + <> + + You’ve reached the limit of placement groups you can create in + this region. + + + {MAXIMUM_NUMBER_OF_PLACEMENT_GROUPS_IN_REGION}{' '} + {getMaxPGsPerCustomer(region)} + + + ), + tooltipWidth: 300, + }; + } + return acc; + }, + {} + ); + return ( { - const isRegionAtCapacity = hasRegionReachedPlacementGroupCapacity( - { - allPlacementGroups: allPlacementGroupsInRegion, - region, - } - ); - - return { - disabled: isRegionAtCapacity, - reason: ( - <> - - You’ve reached the limit of placement groups you can - create in this region. - - - {MAXIMUM_NUMBER_OF_PLACEMENT_GROUPS_IN_REGION}{' '} - {getMaxPGsPerCustomer(region)} - - - ), - tooltipWidth: 300, - }; - }} - handleSelection={(selection) => { - handleRegionSelect(selection); - }} currentCapability="Placement Group" + disableClearable + disabledRegions={disabledRegions} helperText={values.region && pgRegionLimitHelperText} + onChange={(e, region) => handleRegionSelect(region.id)} regions={regions ?? []} - selectedId={selectedRegionId ?? values.region} tooltipText="Only Linode data center regions that support placement groups are listed." + value={selectedRegionId ?? values.region} /> )} { currentCapability="VPCs" disabled={isDrawer ? true : disabled} errorText={errors.region} - handleSelection={(region: string) => onChangeField('region', region)} - isClearable + onChange={(e, region) => onChangeField('region', region?.id ?? '')} regions={regions} - selectedId={values.region} + value={values.region} /> ) => diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx index edcd25709f5..e7bf8c4feb1 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx @@ -120,10 +120,10 @@ export const VPCEditDrawer = (props: Props) => { currentCapability="VPCs" disabled // the Region field will not be editable during beta errorText={(regionsError && regionsError[0].reason) || undefined} - handleSelection={() => null} + onChange={() => null} helperText={REGION_HELPER_TEXT} regions={regionsData} - selectedId={vpc?.region ?? null} + value={vpc?.region ?? null} /> )} { /> { - setFieldValue('region', value); + onChange={(e, region) => { + setFieldValue('region', region?.id ?? null); setFieldValue('linode_id', null); }} currentCapability="Block Storage" disabled={doesNotHavePermission} errorText={touched.region ? errors.region : undefined} - isClearable label="Region" onBlur={handleBlur} regions={regions ?? []} - selectedId={values.region} + value={values.region} width={400} /> {renderSelectTooltip( diff --git a/packages/manager/src/utilities/formatRegion.ts b/packages/manager/src/utilities/formatRegion.ts index f1c2317c2a9..3b8f92c95b6 100644 --- a/packages/manager/src/utilities/formatRegion.ts +++ b/packages/manager/src/utilities/formatRegion.ts @@ -23,7 +23,9 @@ export const getRegionCountryGroup = (region: Region | undefined) => { } const continentCode = - COUNTRY_CODE_TO_CONTINENT_CODE[region.country.toUpperCase() as Country]; + COUNTRY_CODE_TO_CONTINENT_CODE[ + region.country.toUpperCase() as Uppercase + ]; return continentCode ? CONTINENT_CODE_TO_CONTINENT[continentCode] ?? 'Other' From 0e369806d524d5f08402b49259620b8ef5c5e8c8 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 11 Jun 2024 12:40:47 -0400 Subject: [PATCH 067/163] refactor: [M3-8127] - Query Key Factory for Domains (#10559) * domain query keys * Added changeset: Query Key Factory for Domains --------- Co-authored-by: Banks Nussman --- .../pr-10559-tech-stories-1718119071574.md | 5 + packages/manager/src/queries/domains.ts | 234 ++++++++++++------ 2 files changed, 161 insertions(+), 78 deletions(-) create mode 100644 packages/manager/.changeset/pr-10559-tech-stories-1718119071574.md diff --git a/packages/manager/.changeset/pr-10559-tech-stories-1718119071574.md b/packages/manager/.changeset/pr-10559-tech-stories-1718119071574.md new file mode 100644 index 00000000000..32ba2b829c0 --- /dev/null +++ b/packages/manager/.changeset/pr-10559-tech-stories-1718119071574.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Query Key Factory for Domains ([#10559](https://github.com/linode/manager/pull/10559)) diff --git a/packages/manager/src/queries/domains.ts b/packages/manager/src/queries/domains.ts index fb21b5289fe..41ab63b8be9 100644 --- a/packages/manager/src/queries/domains.ts +++ b/packages/manager/src/queries/domains.ts @@ -1,10 +1,4 @@ import { - CloneDomainPayload, - CreateDomainPayload, - Domain, - DomainRecord, - ImportZonePayload, - UpdateDomainPayload, cloneDomain, createDomain, deleteDomain, @@ -13,87 +7,156 @@ import { getDomains, importZone, updateDomain, -} from '@linode/api-v4/lib/domains'; -import { +} from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { getAll } from 'src/utilities/getAll'; + +import { profileQueries } from './profile/profile'; + +import type { APIError, + CloneDomainPayload, + CreateDomainPayload, + Domain, + DomainRecord, Filter, + ImportZonePayload, Params, ResourcePage, -} from '@linode/api-v4/lib/types'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + UpdateDomainPayload, +} from '@linode/api-v4'; +import type { EventHandlerData } from 'src/hooks/useEventHandlers'; -import { EventHandlerData } from 'src/hooks/useEventHandlers'; -import { getAll } from 'src/utilities/getAll'; +export const getAllDomains = () => + getAll((params) => getDomains(params))().then((data) => data.data); -import { profileQueries } from './profile/profile'; +const getAllDomainRecords = (domainId: number) => + getAll((params) => getDomainRecords(domainId, params))().then( + ({ data }) => data + ); -export const queryKey = 'domains'; +const domainQueries = createQueryKeys('domains', { + domain: (id: number) => ({ + contextQueries: { + records: { + queryFn: () => getAllDomainRecords(id), + queryKey: null, + }, + }, + queryFn: () => getDomain(id), + queryKey: [id], + }), + domains: { + contextQueries: { + all: { + queryFn: getAllDomains, + queryKey: null, + }, + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getDomains(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, +}); export const useDomainsQuery = (params: Params, filter: Filter) => - useQuery, APIError[]>( - [queryKey, 'paginated', params, filter], - () => getDomains(params, filter), - { keepPreviousData: true } - ); + useQuery, APIError[]>({ + ...domainQueries.domains._ctx.paginated(params, filter), + keepPreviousData: true, + }); export const useAllDomainsQuery = (enabled: boolean = false) => - useQuery([queryKey, 'all'], getAllDomains, { + useQuery({ + ...domainQueries.domains._ctx.all, enabled, }); export const useDomainQuery = (id: number) => - useQuery([queryKey, 'domain', id], () => getDomain(id)); + useQuery(domainQueries.domain(id)); export const useDomainRecordsQuery = (id: number) => - useQuery( - [queryKey, 'domain', id, 'records'], - () => getAllDomainRecords(id) - ); + useQuery(domainQueries.domain(id)._ctx.records); export const useCreateDomainMutation = () => { const queryClient = useQueryClient(); - return useMutation(createDomain, { - onSuccess: (domain) => { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.setQueryData([queryKey, 'domain', domain.id], domain); + return useMutation({ + mutationFn: createDomain, + onSuccess(domain) { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: domainQueries.domains.queryKey, + }); + + // Set Domain in cache + queryClient.setQueryData( + domainQueries.domain(domain.id).queryKey, + domain + ); + // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries(profileQueries.grants.queryKey); + queryClient.invalidateQueries({ + queryKey: profileQueries.grants.queryKey, + }); }, }); }; export const useCloneDomainMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation( - (data) => cloneDomain(id, data), - { - onSuccess: (domain) => { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.setQueryData([queryKey, 'domain', domain.id], domain); - }, - } - ); + return useMutation({ + mutationFn: (data) => cloneDomain(id, data), + onSuccess(domain) { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: domainQueries.domains.queryKey, + }); + + // Set Domain in cache + queryClient.setQueryData( + domainQueries.domain(domain.id).queryKey, + domain + ); + }, + }); }; export const useImportZoneMutation = () => { const queryClient = useQueryClient(); - return useMutation( - (data) => importZone(data), - { - onSuccess: (domain) => { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.setQueryData([queryKey, 'domain', domain.id], domain); - }, - } - ); + return useMutation({ + mutationFn: importZone, + onSuccess(domain) { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: domainQueries.domains.queryKey, + }); + + // Set Domain in cache + queryClient.setQueryData( + domainQueries.domain(domain.id).queryKey, + domain + ); + }, + }); }; export const useDeleteDomainMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => deleteDomain(id), { - onSuccess: () => { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.removeQueries([queryKey, 'domain', id]); + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteDomain(id), + onSuccess() { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: domainQueries.domains.queryKey, + }); + + // Remove domain (and its sub-queries) from the cache + queryClient.removeQueries({ + queryKey: domainQueries.domain(id).queryKey, + }); }, }); }; @@ -104,33 +167,48 @@ interface UpdateDomainPayloadWithId extends UpdateDomainPayload { export const useUpdateDomainMutation = () => { const queryClient = useQueryClient(); - return useMutation( - (data) => { - const { id, ...rest } = data; - return updateDomain(id, rest); + return useMutation({ + mutationFn: ({ id, ...data }) => updateDomain(id, data), + onSuccess(domain) { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: domainQueries.domains.queryKey, + }); + + // Update domain in cache + queryClient.setQueryData( + domainQueries.domain(domain.id).queryKey, + domain + ); }, - { - onSuccess: (domain) => { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.setQueryData( - [queryKey, 'domain', domain.id], - domain - ); - }, - } - ); + }); }; -export const domainEventsHandler = ({ queryClient }: EventHandlerData) => { - // Invalidation is agressive beacuse it will invalidate on every domain event, but - // it is worth it for the UX benefits. We can fine tune this later if we need to. - queryClient.invalidateQueries([queryKey]); +export const domainEventsHandler = ({ + event, + queryClient, +}: EventHandlerData) => { + const domainId = event.entity?.id; + + if (!domainId) { + return; + } + + if (event.action.startsWith('domain_record')) { + // Invalidate the domain's records because they may have changed + queryClient.invalidateQueries({ + queryKey: domainQueries.domain(domainId)._ctx.records.queryKey, + }); + } else { + // Invalidate paginated lists + queryClient.invalidateQueries({ + queryKey: domainQueries.domains.queryKey, + }); + + // Invalidate the domain's details + queryClient.invalidateQueries({ + exact: true, + queryKey: domainQueries.domain(domainId).queryKey, + }); + } }; - -export const getAllDomains = () => - getAll((params) => getDomains(params))().then((data) => data.data); - -const getAllDomainRecords = (domainId: number) => - getAll((params) => getDomainRecords(domainId, params))().then( - ({ data }) => data - ); From f902075b388cda57234b20a0027bbfdbe17537af Mon Sep 17 00:00:00 2001 From: Hussain Khalil <122488130+hkhalil-akamai@users.noreply.github.com> Date: Tue, 11 Jun 2024 12:45:19 -0400 Subject: [PATCH 068/163] refactor: [M3-8157] - Clean up images drawer (#10487) * Add tags to images type * Add tag select to ImagesDrawer * Added changeset: TagSelect in Edit Image drawer * Added changeset: `tags` field in `Image` type * Further removal of unused logic * Add unit tests for ImagesDrawer * Fix unit tests * Fix action menu items * Add `tags` to `updateImageSchema` * Separate ImagesDrawer into EditImageDrawer and RebuildImageDrawer * Fix deep linking to rebuild linode dialog * Introduce new UpdateImagePayload * saving progress in EditImageDrawer * Implement react-hook-form for EditImageDrawer and RebuildImageDrawer * Reset drawer state when re-opening * Add changeset * Fix unit tests * Feedback @bnussman-akamai * Feedback @bnussman-akamai * Feedback @bnussman-akamai * Feedback @abailly-akamai * Use `onExited` instead of `useEffect` * Use space instead of submitting an empty description --- .../pr-10514-added-1716499303668.md | 5 + packages/api-v4/src/images/images.ts | 17 +- packages/api-v4/src/images/types.ts | 2 + .../pr-10514-tech-stories-1716499244555.md | 5 + .../features/Images/EditImageDrawer.test.tsx | 62 +++++ .../src/features/Images/EditImageDrawer.tsx | 160 ++++++++++++ .../manager/src/features/Images/ImageRow.tsx | 36 +-- .../src/features/Images/ImagesActionMenu.tsx | 57 ++--- .../src/features/Images/ImagesDrawer.test.tsx | 97 -------- .../src/features/Images/ImagesDrawer.tsx | 227 ------------------ .../src/features/Images/ImagesLanding.tsx | 193 ++++----------- .../Images/RebuildImageDrawer.test.tsx | 56 +++++ .../features/Images/RebuildImageDrawer.tsx | 104 ++++++++ .../src/features/Images/utils.test.tsx | 24 +- packages/manager/src/features/Images/utils.ts | 15 +- .../LinodeRebuild/RebuildFromImage.tsx | 13 +- .../Linodes/LinodesDetail/LinodesDetail.tsx | 23 +- packages/manager/src/queries/images.ts | 2 +- 18 files changed, 536 insertions(+), 562 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10514-added-1716499303668.md create mode 100644 packages/manager/.changeset/pr-10514-tech-stories-1716499244555.md create mode 100644 packages/manager/src/features/Images/EditImageDrawer.test.tsx create mode 100644 packages/manager/src/features/Images/EditImageDrawer.tsx delete mode 100644 packages/manager/src/features/Images/ImagesDrawer.test.tsx delete mode 100644 packages/manager/src/features/Images/ImagesDrawer.tsx create mode 100644 packages/manager/src/features/Images/RebuildImageDrawer.test.tsx create mode 100644 packages/manager/src/features/Images/RebuildImageDrawer.tsx diff --git a/packages/api-v4/.changeset/pr-10514-added-1716499303668.md b/packages/api-v4/.changeset/pr-10514-added-1716499303668.md new file mode 100644 index 00000000000..a6853cb0f79 --- /dev/null +++ b/packages/api-v4/.changeset/pr-10514-added-1716499303668.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +UpdateImagePayload type ([#10514](https://github.com/linode/manager/pull/10514)) diff --git a/packages/api-v4/src/images/images.ts b/packages/api-v4/src/images/images.ts index c625a158a85..b012fe396fa 100644 --- a/packages/api-v4/src/images/images.ts +++ b/packages/api-v4/src/images/images.ts @@ -17,6 +17,7 @@ import type { CreateImagePayload, Image, ImageUploadPayload, + UpdateImagePayload, UploadImageResponse, } from './types'; @@ -58,21 +59,9 @@ export const createImage = (data: CreateImagePayload) => { * Updates a private Image that you have permission to read_write. * * @param imageId { string } ID of the Image to look up. - * @param label { string } A short description of the Image. Labels cannot contain special characters. - * @param description { string } A detailed description of this Image. + * @param data { UpdateImagePayload } the updated image details */ -export const updateImage = ( - imageId: string, - label?: string, - description?: string, - tags?: string[] -) => { - const data = { - ...(label && { label }), - ...(description && { description }), - ...(tags && { tags }), - }; - +export const updateImage = (imageId: string, data: UpdateImagePayload) => { return Request( setURL(`${API_ROOT}/images/${encodeURIComponent(imageId)}`), setMethod('PUT'), diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index dd034eda126..48be8aff12a 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -148,6 +148,8 @@ export interface CreateImagePayload extends BaseImagePayload { disk_id: number; } +export type UpdateImagePayload = Omit; + export interface ImageUploadPayload extends BaseImagePayload { label: string; region: string; diff --git a/packages/manager/.changeset/pr-10514-tech-stories-1716499244555.md b/packages/manager/.changeset/pr-10514-tech-stories-1716499244555.md new file mode 100644 index 00000000000..1af3abaf117 --- /dev/null +++ b/packages/manager/.changeset/pr-10514-tech-stories-1716499244555.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Refactor and clean up ImagesDrawer ([#10514](https://github.com/linode/manager/pull/10514)) diff --git a/packages/manager/src/features/Images/EditImageDrawer.test.tsx b/packages/manager/src/features/Images/EditImageDrawer.test.tsx new file mode 100644 index 00000000000..4ee4a28ba84 --- /dev/null +++ b/packages/manager/src/features/Images/EditImageDrawer.test.tsx @@ -0,0 +1,62 @@ +import { fireEvent, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { imageFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { EditImageDrawer } from './EditImageDrawer'; + +const props = { + image: imageFactory.build(), + onClose: vi.fn(), + open: true, +}; + +const mockUpdateImage = vi.fn(); +vi.mock('@linode/api-v4', async () => { + return { + ...(await vi.importActual('@linode/api-v4')), + updateImage: (imageId: any, data: any) => { + mockUpdateImage(imageId, data); + return Promise.resolve(props.image); + }, + }; +}); + +describe('EditImageDrawer', () => { + it('should render', async () => { + const { getByText } = renderWithTheme(); + + // Verify title renders + getByText('Edit Image'); + }); + + it('should allow editing image details', async () => { + const { getByLabelText, getByText } = renderWithTheme( + + ); + + fireEvent.change(getByLabelText('Label'), { + target: { value: 'test-image-label' }, + }); + + fireEvent.change(getByLabelText('Description'), { + target: { value: 'test description' }, + }); + + fireEvent.change(getByLabelText('Tags'), { + target: { value: 'new-tag' }, + }); + fireEvent.click(getByText('Create "new-tag"')); + + fireEvent.click(getByText('Save Changes')); + + await waitFor(() => { + expect(mockUpdateImage).toHaveBeenCalledWith('private/0', { + description: 'test description', + label: 'test-image-label', + tags: ['new-tag'], + }); + }); + }); +}); diff --git a/packages/manager/src/features/Images/EditImageDrawer.tsx b/packages/manager/src/features/Images/EditImageDrawer.tsx new file mode 100644 index 00000000000..eaa00d8f4f1 --- /dev/null +++ b/packages/manager/src/features/Images/EditImageDrawer.tsx @@ -0,0 +1,160 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { APIError, Image, UpdateImagePayload } from '@linode/api-v4'; +import { updateImageSchema } from '@linode/validation'; +import * as React from 'react'; +import { Controller, useForm } from 'react-hook-form'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Drawer } from 'src/components/Drawer'; +import { Notice } from 'src/components/Notice/Notice'; +import { TagsInput } from 'src/components/TagsInput/TagsInput'; +import { TextField } from 'src/components/TextField'; +import { useUpdateImageMutation } from 'src/queries/images'; + +import { useImageAndLinodeGrantCheck } from './utils'; + +interface Props { + image: Image | undefined; + onClose: () => void; + open: boolean; +} +export const EditImageDrawer = (props: Props) => { + const { image, onClose, open } = props; + + const { canCreateImage } = useImageAndLinodeGrantCheck(); + + const defaultValues = { + description: image?.description ?? undefined, + label: image?.label, + tags: image?.tags, + }; + + const { + control, + formState, + handleSubmit, + reset, + setError, + } = useForm({ + defaultValues, + mode: 'onBlur', + resolver: yupResolver(updateImageSchema), + values: defaultValues, + }); + + const { mutateAsync: updateImage } = useUpdateImageMutation(); + + const onSubmit = handleSubmit(async (values) => { + if (!image) { + return; + } + + const safeDescription = values.description?.length + ? values.description + : ' '; + + await updateImage({ + imageId: image.id, + ...values, + description: safeDescription, + }) + .then(onClose) + .catch((errors: APIError[]) => { + for (const error of errors) { + if ( + error.field === 'label' || + error.field == 'description' || + error.field == 'tags' + ) { + setError(error.field, { message: error.reason }); + } else { + setError('root', { message: error.reason }); + } + } + }); + }); + + return ( + + {!canCreateImage && ( + + )} + + {formState.errors.root?.message && ( + + )} + + ( + field.onChange(e.target.value)} + value={field.value} + /> + )} + control={control} + name="label" + /> + + ( + field.onChange(e.target.value)} + rows={1} + value={field.value} + /> + )} + control={control} + name="description" + /> + + ( + ({ label: tag, value: tag })) ?? [] + } + disabled={!canCreateImage} + label="Tags" + onChange={(tags) => field.onChange(tags.map((tag) => tag.value))} + tagError={fieldState.error?.message} + /> + )} + control={control} + name="tags" + /> + + + + ); +}; diff --git a/packages/manager/src/features/Images/ImageRow.tsx b/packages/manager/src/features/Images/ImageRow.tsx index f4cec9bf9f1..605475be3c0 100644 --- a/packages/manager/src/features/Images/ImageRow.tsx +++ b/packages/manager/src/features/Images/ImageRow.tsx @@ -1,5 +1,4 @@ -import { Event } from '@linode/api-v4/lib/account'; -import { Image } from '@linode/api-v4/lib/images'; +import { Event, Image } from '@linode/api-v4'; import * as React from 'react'; import { Hidden } from 'src/components/Hidden'; @@ -12,26 +11,16 @@ import { formatDate } from 'src/utilities/formatDate'; import { Handlers, ImagesActionMenu } from './ImagesActionMenu'; -export interface ImageWithEvent extends Image { +interface Props { event?: Event; + handlers: Handlers; + image: Image; } -interface Props extends Handlers, ImageWithEvent {} - const ImageRow = (props: Props) => { - const { - created, - description, - event, - expiry, - id, - label, - onCancelFailed, - onRetry, - size, - status, - ...rest - } = props; + const { event, image } = props; + + const { created, expiry, id, label, size, status } = image; const { data: profile } = useProfile(); @@ -93,16 +82,7 @@ const ImageRow = (props: Props) => { ) : null} - + ); diff --git a/packages/manager/src/features/Images/ImagesActionMenu.tsx b/packages/manager/src/features/Images/ImagesActionMenu.tsx index 5e9b7ecefda..d50b994a04f 100644 --- a/packages/manager/src/features/Images/ImagesActionMenu.tsx +++ b/packages/manager/src/features/Images/ImagesActionMenu.tsx @@ -1,5 +1,4 @@ -import { Event } from '@linode/api-v4/lib/account'; -import { ImageStatus } from '@linode/api-v4/lib/images/types'; +import { Event, Image, ImageStatus } from '@linode/api-v4'; import * as React from 'react'; import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; @@ -8,13 +7,8 @@ export interface Handlers { onCancelFailed?: (imageID: string) => void; onDelete?: (label: string, imageID: string, status?: ImageStatus) => void; onDeploy?: (imageID: string) => void; - onEdit?: ( - label: string, - description: string, - imageID: string, - tags: string[] - ) => void; - onRestore?: (imageID: string) => void; + onEdit?: (image: Image) => void; + onRestore?: (image: Image) => void; onRetry?: ( imageID: string, label: string, @@ -22,30 +16,25 @@ export interface Handlers { ) => void; } -interface Props extends Handlers { - description: null | string; - event: Event | undefined; - id: string; - label: string; - status?: ImageStatus; - tags: string[]; +interface Props { + event?: Event; + handlers: Handlers; + image: Image; } export const ImagesActionMenu = (props: Props) => { + const { event, handlers, image } = props; + + const { description, id, label, status } = image; + const { - description, - event, - id, - label, onCancelFailed, onDelete, onDeploy, onEdit, onRestore, onRetry, - status, - tags, - } = props; + } = handlers; const actions: Action[] = React.useMemo(() => { const isDisabled = status && status !== 'available'; @@ -65,7 +54,7 @@ export const ImagesActionMenu = (props: Props) => { : [ { disabled: isDisabled, - onClick: () => onEdit?.(label, description ?? ' ', id, tags), + onClick: () => onEdit?.(image), title: 'Edit', tooltip: isDisabled ? 'Image is not yet available for use.' @@ -81,7 +70,7 @@ export const ImagesActionMenu = (props: Props) => { }, { disabled: isDisabled, - onClick: () => onRestore?.(id), + onClick: () => onRestore?.(image), title: 'Rebuild an Existing Linode', tooltip: isDisabled ? 'Image is not yet available for use.' @@ -94,23 +83,23 @@ export const ImagesActionMenu = (props: Props) => { ]; }, [ status, - description, + event, + onRetry, id, label, - onDelete, - onRestore, - onDeploy, - onEdit, - onRetry, + description, onCancelFailed, - event, - tags, + onEdit, + image, + onDeploy, + onRestore, + onDelete, ]); return ( ); }; diff --git a/packages/manager/src/features/Images/ImagesDrawer.test.tsx b/packages/manager/src/features/Images/ImagesDrawer.test.tsx deleted file mode 100644 index 32b4846720f..00000000000 --- a/packages/manager/src/features/Images/ImagesDrawer.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { fireEvent } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; - -import { linodeFactory } from 'src/factories'; -import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { HttpResponse, http, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { ImagesDrawer, Props } from './ImagesDrawer'; - -const props: Props = { - changeDescription: vi.fn(), - changeDisk: vi.fn(), - changeLabel: vi.fn(), - changeLinode: vi.fn(), - changeTags: vi.fn(), - mode: 'edit', - onClose: vi.fn(), - open: true, - selectedLinode: null, -}; - -describe('ImagesDrawer edit mode', () => { - it('should render', async () => { - const { getByText } = renderWithTheme( - - ); - - // Verify title renders - getByText('Edit Image'); - }); - - it('should allow editing image details', async () => { - const { getByLabelText, getByText } = renderWithTheme( - - ); - - fireEvent.change(getByLabelText('Label'), { - target: { value: 'test-image-label' }, - }); - - fireEvent.change(getByLabelText('Description'), { - target: { value: 'test description' }, - }); - - fireEvent.change(getByLabelText('Tags'), { - target: { value: 'new-tag' }, - }); - fireEvent.click(getByText('Create "new-tag"')); - - fireEvent.click(getByText('Save Changes')); - - expect(props.changeLabel).toBeCalledWith( - expect.objectContaining({ - target: expect.objectContaining({ value: 'test-image-label' }), - }) - ); - - expect(props.changeDescription).toBeCalledWith( - expect.objectContaining({ - target: expect.objectContaining({ value: 'test description' }), - }) - ); - - expect(props.changeTags).toBeCalledWith(['new-tag']); - }); -}); - -describe('ImagesDrawer restore mode', () => { - it('should render', async () => { - const { getByText } = renderWithTheme( - - ); - - // Verify title renders - getByText('Restore from Image'); - }); - - it('should allow editing image details', async () => { - const { findByText, getByRole, getByText } = renderWithTheme( - - ); - - server.use( - http.get('*/linode/instances', () => { - return HttpResponse.json(makeResourcePage(linodeFactory.buildList(5))); - }) - ); - - await userEvent.click(getByRole('combobox')); - await userEvent.click(await findByText('linode-1')); - await userEvent.click(getByText('Restore Image')); - - expect(props.changeLinode).toBeCalledWith(1); - }); -}); diff --git a/packages/manager/src/features/Images/ImagesDrawer.tsx b/packages/manager/src/features/Images/ImagesDrawer.tsx deleted file mode 100644 index 444213ccaed..00000000000 --- a/packages/manager/src/features/Images/ImagesDrawer.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import { APIError } from '@linode/api-v4/lib/types'; -import * as React from 'react'; -import { useHistory } from 'react-router-dom'; - -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; -import { Notice } from 'src/components/Notice/Notice'; -import { TagsInput } from 'src/components/TagsInput/TagsInput'; -import { TextField } from 'src/components/TextField'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; -import { useUpdateImageMutation } from 'src/queries/images'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; - -import { useImageAndLinodeGrantCheck } from './utils'; - -export interface Props { - changeDescription: (e: React.ChangeEvent) => void; - changeDisk: (disk: null | string) => void; - changeLabel: (e: React.ChangeEvent) => void; - changeLinode: (linodeId: number) => void; - changeTags: (tags: string[]) => void; - description?: string; - imageId?: string; - label?: string; - mode: DrawerMode; - onClose: () => void; - open?: boolean; - selectedLinode: null | number; - tags?: string[]; -} - -type CombinedProps = Props; - -export type DrawerMode = 'edit' | 'restore'; - -const titleMap: Record = { - edit: 'Edit Image', - restore: 'Restore from Image', -}; - -const buttonTextMap: Record = { - edit: 'Save Changes', - restore: 'Restore Image', -}; - -export const ImagesDrawer = (props: CombinedProps) => { - const { - changeDescription, - changeLabel, - changeLinode, - changeTags, - description, - imageId, - label, - mode, - onClose, - open, - selectedLinode, - tags, - } = props; - - const history = useHistory(); - const { - canCreateImage, - permissionedLinodes: availableLinodes, - } = useImageAndLinodeGrantCheck(); - - const [notice, setNotice] = React.useState(undefined); - const [submitting, setSubmitting] = React.useState(false); - const [errors, setErrors] = React.useState(undefined); - - const { mutateAsync: updateImage } = useUpdateImageMutation(); - - const handleLinodeChange = (linodeID: number) => { - // Clear any errors - setErrors(undefined); - changeLinode(linodeID); - }; - - const safeDescription = description ? description : ' '; - - const onSubmit = () => { - setErrors(undefined); - setNotice(undefined); - setSubmitting(true); - - switch (mode) { - case 'edit': - if (!imageId) { - setSubmitting(false); - return; - } - - updateImage({ description: safeDescription, imageId, label, tags }) - .then(onClose) - .catch((errorResponse: APIError[]) => { - setErrors( - getAPIErrorOrDefault(errorResponse, 'Unable to edit Image') - ); - }) - .finally(() => { - setSubmitting(false); - }); - return; - - case 'restore': - if (!selectedLinode) { - setSubmitting(false); - setErrors([{ field: 'linode_id', reason: 'Choose a Linode.' }]); - return; - } - close(); - history.push({ - pathname: `/linodes/${selectedLinode}/rebuild`, - state: { selectedImageId: imageId }, - }); - default: - return; - } - }; - - const hasErrorFor = getAPIErrorFor( - { - disk_id: 'Disk', - label: 'Label', - linode_id: 'Linode', - region: 'Region', - size: 'Size', - }, - errors - ); - const labelError = hasErrorFor('label'); - const descriptionError = hasErrorFor('description'); - const generalError = hasErrorFor('none'); - const linodeError = hasErrorFor('linode_id'); - const tagsError = hasErrorFor('tags'); - - return ( - { - setErrors(undefined); - }} - onClose={onClose} - open={open} - title={titleMap[mode]} - > - {!canCreateImage ? ( - - ) : null} - {generalError && ( - - )} - - {notice && } - - {mode === 'restore' && ( - { - if (linode !== null) { - handleLinodeChange(linode.id); - } - }} - optionsFilter={(linode) => - availableLinodes ? availableLinodes.includes(linode.id) : true - } - clearable={false} - disabled={!canCreateImage} - errorText={linodeError} - value={selectedLinode} - /> - )} - - {mode === 'edit' && ( - <> - - - changeTags(tags.map((tag) => tag.value))} - tagError={tagsError} - value={tags?.map((t) => ({ label: t, value: t })) ?? []} - /> - - )} - - - - ); -}; diff --git a/packages/manager/src/features/Images/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding.tsx index d52729e5923..2e27506b3bf 100644 --- a/packages/manager/src/features/Images/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding.tsx @@ -1,8 +1,7 @@ -import { Event, Image, ImageStatus } from '@linode/api-v4'; +import { Image, ImageStatus } from '@linode/api-v4'; import { APIError } from '@linode/api-v4/lib/types'; import { Theme } from '@mui/material/styles'; import { useQueryClient } from '@tanstack/react-query'; -import produce from 'immer'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; @@ -28,7 +27,6 @@ import { TableSortCell } from 'src/components/TableSortCell'; import { Typography } from 'src/components/Typography'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; -import { listToItemsByID } from 'src/queries/base'; import { isEventImageUpload, isEventInProgressDiskImagize, @@ -41,10 +39,12 @@ import { } from 'src/queries/images'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; -import ImageRow, { ImageWithEvent } from './ImageRow'; +import { EditImageDrawer } from './EditImageDrawer'; +import ImageRow from './ImageRow'; import { Handlers as ImageHandlers } from './ImagesActionMenu'; -import { DrawerMode, ImagesDrawer } from './ImagesDrawer'; import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; +import { RebuildImageDrawer } from './RebuildImageDrawer'; +import { getEventsForImages } from './utils'; const useStyles = makeStyles()((theme: Theme) => ({ imageTable: { @@ -60,16 +60,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); -interface ImageDrawerState { - description?: string; - imageID?: string; - label?: string; - mode: DrawerMode; - open: boolean; - selectedLinode?: number; - tags?: string[]; -} - interface ImageDialogState { error?: string; image?: string; @@ -79,16 +69,6 @@ interface ImageDialogState { submitting: boolean; } -interface ImagesLandingProps extends ImageDrawerState, ImageDialogState {} - -const defaultDrawerState: ImageDrawerState = { - description: '', - label: '', - mode: 'edit', - open: false, - tags: [], -}; - const defaultDialogState = { error: undefined, image: '', @@ -97,7 +77,7 @@ const defaultDialogState = { submitting: false, }; -export const ImagesLanding: React.FC = () => { +export const ImagesLanding = () => { const { classes } = useStyles(); const history = useHistory(); const { enqueueSnackbar } = useSnackbar(); @@ -191,19 +171,23 @@ export const ImagesLanding: React.FC = () => { ) ?? []; // Private images with the associated events tied in. - const manualImagesData = getImagesWithEvents( + const manualImagesEvents = getEventsForImages( manualImages?.data ?? [], imageEvents ); // Automatic images with the associated events tied in. - const automaticImagesData = getImagesWithEvents( + const automaticImagesEvents = getEventsForImages( automaticImages?.data ?? [], imageEvents ); - const [drawer, setDrawer] = React.useState( - defaultDrawerState + const [selectedImage, setSelectedImage] = React.useState(); + + const [editDrawerOpen, setEditDrawerOpen] = React.useState(false); + + const [rebuildDrawerOpen, setRebuildDrawerOpen] = React.useState( + false ); const [dialog, setDialogState] = React.useState( @@ -290,28 +274,14 @@ export const ImagesLanding: React.FC = () => { queryClient.invalidateQueries(imageQueries.paginated._def); }; - const openForEdit = ( - label: string, - description: string, - imageID: string, - tags: string[] - ) => { - setDrawer({ - description, - imageID, - label, - mode: 'edit', - open: true, - tags, - }); + const openForEdit = (image: Image) => { + setSelectedImage(image); + setEditDrawerOpen(true); }; - const openForRestore = (imageID: string) => { - setDrawer({ - imageID, - mode: 'restore', - open: true, - }); + const openForRestore = (image: Image) => { + setSelectedImage(image); + setRebuildDrawerOpen(true); }; const deployNewLinode = (imageID: string) => { @@ -322,44 +292,6 @@ export const ImagesLanding: React.FC = () => { }); }; - const changeSelectedLinode = (linodeId: null | number) => { - setDrawer((prevDrawerState) => ({ - ...prevDrawerState, - selectedDisk: null, - selectedLinode: linodeId ?? undefined, - })); - }; - - const changeSelectedDisk = (disk: null | string) => { - setDrawer((prevDrawerState) => ({ - ...prevDrawerState, - selectedDisk: disk, - })); - }; - - const setLabel = (e: React.ChangeEvent) => { - const value = e.target.value; - - setDrawer((prevDrawerState) => ({ - ...prevDrawerState, - label: value, - })); - }; - - const setDescription = (e: React.ChangeEvent) => { - const value = e.target.value; - setDrawer((prevDrawerState) => ({ - ...prevDrawerState, - description: value, - })); - }; - - const setTags = (tags: string[]) => - setDrawer((prevDrawerState) => ({ - ...prevDrawerState, - tags, - })); - const getActions = () => { return ( = () => { ); }; - const closeImageDrawer = () => { - setDrawer((prevDrawerState) => ({ - ...prevDrawerState, - open: false, - })); - }; - - const renderImageDrawer = () => { - return ( - - ); - }; - const handlers: ImageHandlers = { onCancelFailed: onCancelFailedClick, onDelete: openDialog, @@ -445,10 +350,7 @@ export const ImagesLanding: React.FC = () => { } /** Empty States */ - if ( - (!manualImagesData || manualImagesData.length === 0) && - (!automaticImagesData || automaticImagesData.length === 0) - ) { + if (!manualImages.data.length && !automaticImages.data.length) { return renderEmpty(); } @@ -513,12 +415,13 @@ export const ImagesLanding: React.FC = () => { - {manualImagesData.length > 0 - ? manualImagesData.map((manualImage) => ( + {manualImages.data.length > 0 + ? manualImages.data.map((manualImage) => ( )) : noManualImages} @@ -580,12 +483,13 @@ export const ImagesLanding: React.FC = () => { - {automaticImagesData.length > 0 - ? automaticImagesData.map((automaticImage) => ( + {automaticImages.data.length > 0 + ? automaticImages.data.map((automaticImage) => ( )) : noAutomaticImages} @@ -600,7 +504,16 @@ export const ImagesLanding: React.FC = () => { pageSize={paginationForAutomaticImages.pageSize} /> - {renderImageDrawer()} + setEditDrawerOpen(false)} + open={editDrawerOpen} + /> + setRebuildDrawerOpen(false)} + open={rebuildDrawerOpen} + /> = () => { }; export default ImagesLanding; - -const getImagesWithEvents = (images: Image[], events: Event[]) => { - const itemsById = listToItemsByID(images ?? []); - return Object.values(itemsById).reduce( - (accum, thisImage: Image) => - produce(accum, (draft: any) => { - if (!thisImage.is_public) { - // NB: the secondary_entity returns only the numeric portion of the image ID so we have to interpolate. - const matchingEvent = events.find( - (thisEvent) => - `private/${thisEvent.secondary_entity?.id}` === thisImage.id || - (`private/${thisEvent.entity?.id}` === thisImage.id && - thisEvent.status === 'failed') - ); - if (matchingEvent) { - draft.push({ ...thisImage, event: matchingEvent }); - } else { - draft.push(thisImage); - } - } - }), - [] - ) as ImageWithEvent[]; -}; diff --git a/packages/manager/src/features/Images/RebuildImageDrawer.test.tsx b/packages/manager/src/features/Images/RebuildImageDrawer.test.tsx new file mode 100644 index 00000000000..1214868b31d --- /dev/null +++ b/packages/manager/src/features/Images/RebuildImageDrawer.test.tsx @@ -0,0 +1,56 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { imageFactory, linodeFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { RebuildImageDrawer } from './RebuildImageDrawer'; + +const props = { + changeLinode: vi.fn(), + image: imageFactory.build(), + onClose: vi.fn(), + open: true, +}; + +const mockHistoryPush = vi.fn(); +vi.mock('react-router-dom', async () => { + return { + ...(await vi.importActual('react-router-dom')), + useHistory: () => ({ + push: mockHistoryPush, + }), + }; +}); + +describe('RebuildImageDrawer', () => { + it('should render', async () => { + const { getByText } = renderWithTheme(); + + // Verify title renders + getByText('Restore from Image'); + }); + + it('should allow selecting a Linode to rebuild', async () => { + const { findByText, getByRole, getByText } = renderWithTheme( + + ); + + server.use( + http.get('*/linode/instances', () => { + return HttpResponse.json(makeResourcePage(linodeFactory.buildList(5))); + }) + ); + + await userEvent.click(getByRole('combobox')); + await userEvent.click(await findByText('linode-1')); + await userEvent.click(getByText('Restore Image')); + + expect(mockHistoryPush).toBeCalledWith({ + pathname: '/linodes/1/rebuild', + search: 'selectedImageId=private%2F0', + }); + }); +}); diff --git a/packages/manager/src/features/Images/RebuildImageDrawer.tsx b/packages/manager/src/features/Images/RebuildImageDrawer.tsx new file mode 100644 index 00000000000..390a7d8e24c --- /dev/null +++ b/packages/manager/src/features/Images/RebuildImageDrawer.tsx @@ -0,0 +1,104 @@ +import { Image } from '@linode/api-v4'; +import * as React from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { useHistory } from 'react-router-dom'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Drawer } from 'src/components/Drawer'; +import { Notice } from 'src/components/Notice/Notice'; +import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; + +import { REBUILD_LINODE_IMAGE_PARAM_NAME } from '../Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage'; +import { useImageAndLinodeGrantCheck } from './utils'; + +interface Props { + image: Image | undefined; + onClose: () => void; + open?: boolean; +} + +export const RebuildImageDrawer = (props: Props) => { + const { image, onClose, open } = props; + + const history = useHistory(); + const { + permissionedLinodes: availableLinodes, + } = useImageAndLinodeGrantCheck(); + + const { control, formState, handleSubmit, reset } = useForm<{ + linodeId: number; + }>({ + defaultValues: { linodeId: undefined }, + mode: 'onBlur', + }); + + const onSubmit = handleSubmit((values) => { + if (!image) { + return; + } + + onClose(); + + history.push({ + pathname: `/linodes/${values.linodeId}/rebuild`, + search: new URLSearchParams({ + [REBUILD_LINODE_IMAGE_PARAM_NAME]: image.id, + }).toString(), + }); + }); + + return ( + + {formState.errors.root?.message && ( + + )} + + ( + { + field.onChange(linode?.id); + }} + optionsFilter={(linode) => + availableLinodes ? availableLinodes.includes(linode.id) : true + } + clearable={true} + errorText={fieldState.error?.message} + onBlur={field.onBlur} + value={field.value} + /> + )} + rules={{ + required: { + message: 'Select a Linode to restore.', + value: true, + }, + }} + control={control} + name="linodeId" + /> + + + + ); +}; diff --git a/packages/manager/src/features/Images/utils.test.tsx b/packages/manager/src/features/Images/utils.test.tsx index 40bcdf61f1e..81dc5f25783 100644 --- a/packages/manager/src/features/Images/utils.test.tsx +++ b/packages/manager/src/features/Images/utils.test.tsx @@ -1,6 +1,6 @@ -import { imageFactory, linodeFactory } from 'src/factories'; +import { eventFactory, imageFactory, linodeFactory } from 'src/factories'; -import { getImageLabelForLinode } from './utils'; +import { getEventsForImages, getImageLabelForLinode } from './utils'; describe('getImageLabelForLinode', () => { it('handles finding an image and getting the label', () => { @@ -31,3 +31,23 @@ describe('getImageLabelForLinode', () => { expect(getImageLabelForLinode(linode, images)).toBe(null); }); }); + +describe('getEventsForImages', () => { + it('sorts events by image', () => { + imageFactory.resetSequenceNumber(); + const images = imageFactory.buildList(3); + const successfulEvent = eventFactory.build({ secondary_entity: { id: 0 } }); + const failedEvent = eventFactory.build({ + entity: { id: 1 }, + status: 'failed', + }); + const unrelatedEvent = eventFactory.build(); + + expect( + getEventsForImages(images, [successfulEvent, failedEvent, unrelatedEvent]) + ).toEqual({ + ['private/0']: successfulEvent, + ['private/1']: failedEvent, + }); + }); +}); diff --git a/packages/manager/src/features/Images/utils.ts b/packages/manager/src/features/Images/utils.ts index 193eda19887..8f23a81e38f 100644 --- a/packages/manager/src/features/Images/utils.ts +++ b/packages/manager/src/features/Images/utils.ts @@ -1,6 +1,6 @@ import { useGrants, useProfile } from 'src/queries/profile/profile'; -import type { Image, Linode } from '@linode/api-v4'; +import type { Event, Image, Linode } from '@linode/api-v4'; export const useImageAndLinodeGrantCheck = () => { const { data: profile } = useProfile(); @@ -25,3 +25,16 @@ export const getImageLabelForLinode = (linode: Linode, images: Image[]) => { const image = images?.find((image) => image.id === linode.image); return image?.label ?? linode.image; }; + +export const getEventsForImages = (images: Image[], events: Event[]) => + Object.fromEntries( + images.map(({ id: imageId }) => [ + imageId, + events.find( + (thisEvent) => + `private/${thisEvent.secondary_entity?.id}` === imageId || + (`private/${thisEvent.entity?.id}` === imageId && + thisEvent.status === 'failed') + ), + ]) + ); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx index e0155d4adc5..25bb9766d3c 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx @@ -9,6 +9,7 @@ import { Formik, FormikProps } from 'formik'; import { useSnackbar } from 'notistack'; import { isEmpty } from 'ramda'; import * as React from 'react'; +import { useLocation } from 'react-router-dom'; import { AccessPanel } from 'src/components/AccessPanel/AccessPanel'; import { Box } from 'src/components/Box'; @@ -28,6 +29,7 @@ import { handleFieldErrors, handleGeneralErrors, } from 'src/utilities/formikErrorUtils'; +import { getQueryParamFromQueryString } from 'src/utilities/queryParams'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { extendValidationSchema } from 'src/utilities/validatePassword'; @@ -63,6 +65,8 @@ const initialValues: RebuildFromImageForm = { root_pass: '', }; +export const REBUILD_LINODE_IMAGE_PARAM_NAME = 'selectedImageId'; + export const RebuildFromImage = (props: Props) => { const { disabled, @@ -101,6 +105,13 @@ export const RebuildFromImage = (props: Props) => { false ); + const location = useLocation(); + const preselectedImageId = getQueryParamFromQueryString( + location.search, + REBUILD_LINODE_IMAGE_PARAM_NAME, + '' + ); + const handleUserDataChange = (userData: string) => { setUserData(userData); }; @@ -182,7 +193,7 @@ export const RebuildFromImage = (props: Props) => { return ( import('./LinodesDetailHeader/LinodeDetailHeader') @@ -23,6 +25,9 @@ const CloneLanding = React.lazy(() => import('../CloneLanding/CloneLanding')); const LinodeDetail = () => { const { path, url } = useRouteMatch(); const { linodeId } = useParams<{ linodeId: string }>(); + const location = useLocation(); + + const queryParams = getQueryParamsFromQueryString(location.search); const id = Number(linodeId); @@ -46,11 +51,19 @@ const LinodeDetail = () => { have to reload all the configs, disks, etc. once we get to the CloneLanding page. */} - - - - - + {['resize', 'rescue', 'migrate', 'upgrade', 'rebuild'].map((path) => ( + + ))} ( diff --git a/packages/manager/src/queries/images.ts b/packages/manager/src/queries/images.ts index 2318ef0deee..93d2717850b 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/manager/src/queries/images.ts @@ -83,7 +83,7 @@ export const useUpdateImageMutation = () => { { description?: string; imageId: string; label?: string; tags?: string[] } >({ mutationFn: ({ description, imageId, label, tags }) => - updateImage(imageId, label, description, tags), + updateImage(imageId, { description, label, tags }), onSuccess(image) { queryClient.invalidateQueries(imageQueries.paginated._def); queryClient.setQueryData( From 79ea6a30e2e2d870ff747ceb1ba2aaafcb9844b9 Mon Sep 17 00:00:00 2001 From: Hana Xu Date: Tue, 11 Jun 2024 13:06:28 -0400 Subject: [PATCH 069/163] do not display edge plans when a core region is selected --- .../src/features/components/PlansPanel/PlansPanel.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx index 432a1670a0c..69813cf0f6f 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx @@ -86,9 +86,14 @@ export const PlansPanel = (props: PlansPanelProps) => { Boolean(flags.soldOutChips) && selectedRegionID !== undefined ); - const _types = replaceOrAppendPlaceholder512GbPlans(types); + const _types = types.filter( + (type) => + !type.id.includes('dedicated-edge') && !type.id.includes('nanode-edge') + ); const _plans = getPlanSelectionsByPlanType( - flags.disableLargestGbPlans ? _types : types + flags.disableLargestGbPlans + ? replaceOrAppendPlaceholder512GbPlans(_types) + : _types ); const hideDistributedRegions = From 1b6b0e850033c8c5995ddfb4eaa028ed6eca1b96 Mon Sep 17 00:00:00 2001 From: Hana Xu Date: Tue, 11 Jun 2024 13:41:51 -0400 Subject: [PATCH 070/163] update package version and changelog --- packages/manager/CHANGELOG.md | 7 ++++++- packages/manager/package.json | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 9a5a69d2e36..cf21e9f2632 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,8 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [2024-06-10] - v1.121.0 +## [2024-06-11] - v1.121.1 + +### Fixed: +- Core Plan table display ([#10567](https://github.com/linode/manager/pull/10567)) + +## [2024-06-10] - v1.121.0 ### Added: diff --git a/packages/manager/package.json b/packages/manager/package.json index 0e58e8a5ea6..bd23d36d5d8 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.121.0", + "version": "1.121.1", "private": true, "type": "module", "bugs": { From 2fa3151a531b39bef3d47426f9462e90ba9496c9 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Tue, 11 Jun 2024 15:03:53 -0400 Subject: [PATCH 071/163] test: Upgrade Vitest and related dependencies to v1.6.0 (#10561) * Upgrade Vitest and related dependencies to v1.6.0 * Added changeset: Upgrade Vitest and related dependencies to 1.6.0 --- packages/api-v4/package.json | 2 +- .../pr-10561-tech-stories-1718047853944.md | 5 + packages/manager/package.json | 9 +- yarn.lock | 307 ++++++++---------- 4 files changed, 151 insertions(+), 172 deletions(-) create mode 100644 packages/manager/.changeset/pr-10561-tech-stories-1718047853944.md diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 71217ceb004..ffd3e5944d4 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -67,7 +67,7 @@ "lint-staged": "^13.2.2", "prettier": "~2.2.1", "tsup": "^7.2.0", - "vitest": "^1.3.1" + "vitest": "^1.6.0" }, "lint-staged": { "*.{ts,tsx,js}": [ diff --git a/packages/manager/.changeset/pr-10561-tech-stories-1718047853944.md b/packages/manager/.changeset/pr-10561-tech-stories-1718047853944.md new file mode 100644 index 00000000000..08c24258388 --- /dev/null +++ b/packages/manager/.changeset/pr-10561-tech-stories-1718047853944.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Upgrade Vitest and related dependencies to 1.6.0 ([#10561](https://github.com/linode/manager/pull/10561)) diff --git a/packages/manager/package.json b/packages/manager/package.json index 0e58e8a5ea6..d4a03d51a05 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -128,8 +128,9 @@ "@storybook/theming": "^8.1.0", "@swc/core": "^1.3.1", "@testing-library/cypress": "^10.0.2", + "@testing-library/dom": "^10.1.0", "@testing-library/jest-dom": "~6.4.2", - "@testing-library/react": "~14.2.1", + "@testing-library/react": "~16.0.0", "@testing-library/user-event": "^14.5.2", "@types/braintree-web": "^3.75.23", "@types/chai-string": "^1.4.5", @@ -167,8 +168,8 @@ "@typescript-eslint/eslint-plugin": "^4.1.1", "@typescript-eslint/parser": "^4.1.1", "@vitejs/plugin-react-swc": "^3.5.0", - "@vitest/coverage-v8": "^1.0.4", - "@vitest/ui": "^1.0.4", + "@vitest/coverage-v8": "^1.6.0", + "@vitest/ui": "^1.6.0", "chai-string": "^1.5.0", "chalk": "^5.2.0", "commander": "^6.2.1", @@ -212,7 +213,7 @@ "ts-node": "^10.9.2", "vite": "^5.1.7", "vite-plugin-svgr": "^3.2.0", - "vitest": "^1.3.1" + "vitest": "^1.6.0" }, "browserslist": [ ">1%", diff --git a/yarn.lock b/yarn.lock index dcc8ddb93b5..10ca9a89bb6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -139,7 +139,7 @@ "@babel/highlight" "^7.23.4" chalk "^2.4.2" -"@babel/code-frame@^7.24.1", "@babel/code-frame@^7.24.2": +"@babel/code-frame@^7.24.2": version "7.24.2" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.2.tgz#718b4b19841809a58b29b68cde80bc5e1aa6d9ae" integrity sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ== @@ -147,6 +147,14 @@ "@babel/highlight" "^7.24.2" picocolors "^1.0.0" +"@babel/code-frame@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.7.tgz#882fd9e09e8ee324e496bd040401c6f046ef4465" + integrity sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA== + dependencies: + "@babel/highlight" "^7.24.7" + picocolors "^1.0.0" + "@babel/compat-data@^7.22.6", "@babel/compat-data@^7.23.5": version "7.23.5" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.23.5.tgz#ffb878728bb6bdcb6f4510aa51b1be9afb8cfd98" @@ -209,16 +217,6 @@ "@jridgewell/trace-mapping" "^0.3.17" jsesc "^2.5.1" -"@babel/generator@^7.24.1": - version "7.24.1" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.1.tgz#e67e06f68568a4ebf194d1c6014235344f0476d0" - integrity sha512-DfCRfZsBcrPEHUfuBMgbJ1Ut01Y/itOs+hY2nFLgqsqXd52/iSiVq5TITtUasIUgm+IIKdY2/1I7auiQOEeC9A== - dependencies: - "@babel/types" "^7.24.0" - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.25" - jsesc "^2.5.1" - "@babel/generator@^7.24.4", "@babel/generator@^7.24.5": version "7.24.5" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.5.tgz#e5afc068f932f05616b66713e28d0f04e99daeb3" @@ -229,6 +227,16 @@ "@jridgewell/trace-mapping" "^0.3.25" jsesc "^2.5.1" +"@babel/generator@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.7.tgz#1654d01de20ad66b4b4d99c135471bc654c55e6d" + integrity sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA== + dependencies: + "@babel/types" "^7.24.7" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^2.5.1" + "@babel/helper-annotate-as-pure@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz#e7f06737b197d580a01edf75d97e2c8be99d3882" @@ -309,6 +317,13 @@ resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167" integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA== +"@babel/helper-environment-visitor@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz#4b31ba9551d1f90781ba83491dd59cf9b269f7d9" + integrity sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ== + dependencies: + "@babel/types" "^7.24.7" + "@babel/helper-function-name@^7.22.5", "@babel/helper-function-name@^7.23.0": version "7.23.0" resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759" @@ -317,6 +332,14 @@ "@babel/template" "^7.22.15" "@babel/types" "^7.23.0" +"@babel/helper-function-name@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz#75f1e1725742f39ac6584ee0b16d94513da38dd2" + integrity sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA== + dependencies: + "@babel/template" "^7.24.7" + "@babel/types" "^7.24.7" + "@babel/helper-hoist-variables@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb" @@ -324,6 +347,13 @@ dependencies: "@babel/types" "^7.22.5" +"@babel/helper-hoist-variables@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz#b4ede1cde2fd89436397f30dc9376ee06b0f25ee" + integrity sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ== + dependencies: + "@babel/types" "^7.24.7" + "@babel/helper-member-expression-to-functions@^7.22.15", "@babel/helper-member-expression-to-functions@^7.23.0": version "7.23.0" resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz#9263e88cc5e41d39ec18c9a3e0eced59a3e7d366" @@ -453,6 +483,13 @@ dependencies: "@babel/types" "^7.24.5" +"@babel/helper-split-export-declaration@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz#83949436890e07fa3d6873c61a96e3bbf692d856" + integrity sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA== + dependencies: + "@babel/types" "^7.24.7" + "@babel/helper-string-parser@^7.23.4": version "7.23.4" resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz#9478c707febcbbe1ddb38a3d91a2e054ae622d83" @@ -463,6 +500,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz#f99c36d3593db9540705d0739a1f10b5e20c696e" integrity sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ== +"@babel/helper-string-parser@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz#4d2d0f14820ede3b9807ea5fc36dfc8cd7da07f2" + integrity sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg== + "@babel/helper-validator-identifier@^7.22.20": version "7.22.20" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0" @@ -473,6 +515,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz#918b1a7fa23056603506370089bd990d8720db62" integrity sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA== +"@babel/helper-validator-identifier@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz#75b889cfaf9e35c2aaf42cf0d72c8e91719251db" + integrity sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w== + "@babel/helper-validator-option@^7.22.15", "@babel/helper-validator-option@^7.23.5": version "7.23.5" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz#907a3fbd4523426285365d1206c423c4c5520307" @@ -524,6 +571,16 @@ js-tokens "^4.0.0" picocolors "^1.0.0" +"@babel/highlight@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.7.tgz#a05ab1df134b286558aae0ed41e6c5f731bf409d" + integrity sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw== + dependencies: + "@babel/helper-validator-identifier" "^7.24.7" + chalk "^2.4.2" + js-tokens "^4.0.0" + picocolors "^1.0.0" + "@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.23.0", "@babel/parser@^7.23.6", "@babel/parser@^7.23.9", "@babel/parser@^7.7.0": version "7.23.9" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.9.tgz#7b903b6149b0f8fa7ad564af646c4c38a77fc44b" @@ -534,16 +591,16 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.0.tgz#26a3d1ff49031c53a97d03b604375f028746a9ac" integrity sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg== -"@babel/parser@^7.24.1": - version "7.24.1" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.1.tgz#1e416d3627393fab1cb5b0f2f1796a100ae9133a" - integrity sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg== - "@babel/parser@^7.24.4", "@babel/parser@^7.24.5": version "7.24.5" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.5.tgz#4a4d5ab4315579e5398a82dcf636ca80c3392790" integrity sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg== +"@babel/parser@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.7.tgz#9a5226f92f0c5c8ead550b750f5608e766c8ce85" + integrity sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw== + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.24.5": version "7.24.5" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.24.5.tgz#4c3685eb9cd790bcad2843900fe0250c91ccf895" @@ -1333,19 +1390,28 @@ "@babel/parser" "^7.24.0" "@babel/types" "^7.24.0" -"@babel/traverse@^7.18.9", "@babel/traverse@^7.23.3", "@babel/traverse@^7.23.9", "@babel/traverse@^7.24.1", "@babel/traverse@^7.24.5", "@babel/traverse@^7.7.0": - version "7.24.1" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.1.tgz#d65c36ac9dd17282175d1e4a3c49d5b7988f530c" - integrity sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ== +"@babel/template@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.7.tgz#02efcee317d0609d2c07117cb70ef8fb17ab7315" + integrity sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig== dependencies: - "@babel/code-frame" "^7.24.1" - "@babel/generator" "^7.24.1" - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-function-name" "^7.23.0" - "@babel/helper-hoist-variables" "^7.22.5" - "@babel/helper-split-export-declaration" "^7.22.6" - "@babel/parser" "^7.24.1" - "@babel/types" "^7.24.0" + "@babel/code-frame" "^7.24.7" + "@babel/parser" "^7.24.7" + "@babel/types" "^7.24.7" + +"@babel/traverse@^7.18.9", "@babel/traverse@^7.23.3", "@babel/traverse@^7.23.9", "@babel/traverse@^7.24.1", "@babel/traverse@^7.24.5", "@babel/traverse@^7.7.0": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.7.tgz#de2b900163fa741721ba382163fe46a936c40cf5" + integrity sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA== + dependencies: + "@babel/code-frame" "^7.24.7" + "@babel/generator" "^7.24.7" + "@babel/helper-environment-visitor" "^7.24.7" + "@babel/helper-function-name" "^7.24.7" + "@babel/helper-hoist-variables" "^7.24.7" + "@babel/helper-split-export-declaration" "^7.24.7" + "@babel/parser" "^7.24.7" + "@babel/types" "^7.24.7" debug "^4.3.1" globals "^11.1.0" @@ -1376,6 +1442,15 @@ "@babel/helper-validator-identifier" "^7.24.5" to-fast-properties "^2.0.0" +"@babel/types@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.7.tgz#6027fe12bc1aa724cd32ab113fb7f1988f1f66f2" + integrity sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q== + dependencies: + "@babel/helper-string-parser" "^7.24.7" + "@babel/helper-validator-identifier" "^7.24.7" + to-fast-properties "^2.0.0" + "@base2/pretty-print-object@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@base2/pretty-print-object/-/pretty-print-object-1.0.1.tgz#371ba8be66d556812dc7fb169ebc3c08378f69d4" @@ -2099,7 +2174,7 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9": +"@jridgewell/trace-mapping@^0.3.17", "@jridgewell/trace-mapping@^0.3.9": version "0.3.22" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz#72a621e5de59f5f1ef792d0793a82ee20f645e4c" integrity sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw== @@ -2107,7 +2182,7 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": +"@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": version "0.3.25" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== @@ -3559,20 +3634,6 @@ lz-string "^1.5.0" pretty-format "^27.0.2" -"@testing-library/dom@^9.0.0": - version "9.3.4" - resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.3.4.tgz#50696ec28376926fec0a1bf87d9dbac5e27f60ce" - integrity sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ== - dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/runtime" "^7.12.5" - "@types/aria-query" "^5.0.1" - aria-query "5.1.3" - chalk "^4.1.0" - dom-accessibility-api "^0.5.9" - lz-string "^1.5.0" - pretty-format "^27.0.2" - "@testing-library/jest-dom@~6.4.2": version "6.4.2" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.4.2.tgz#38949f6b63722900e2d75ba3c6d9bf8cffb3300e" @@ -3587,14 +3648,12 @@ lodash "^4.17.15" redent "^3.0.0" -"@testing-library/react@~14.2.1": - version "14.2.1" - resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-14.2.1.tgz#bf69aa3f71c36133349976a4a2da3687561d8310" - integrity sha512-sGdjws32ai5TLerhvzThYFbpnF9XtL65Cjf+gB0Dhr29BGqK+mAeN7SURSdu+eqgET4ANcWoC7FQpkaiGvBr+A== +"@testing-library/react@~16.0.0": + version "16.0.0" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.0.0.tgz#0a1e0c7a3de25841c3591b8cb7fb0cf0c0a27321" + integrity sha512-guuxUKRWQ+FgNX0h0NS0FIq3Q3uLtWVpBzcLOggmfMoUpgBnzBzvLLd4fbm6yS8ydJd94cIfY4yP9qUQjM2KwQ== dependencies: "@babel/runtime" "^7.12.5" - "@testing-library/dom" "^9.0.0" - "@types/react-dom" "^18.0.0" "@testing-library/user-event@^14.5.2": version "14.5.2" @@ -3913,11 +3972,6 @@ resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.4.tgz#7eb47726c391b7345a6ec35ad7f4de469cf5ba4f" integrity sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA== -"@types/istanbul-lib-coverage@^2.0.1": - version "2.0.6" - resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" - integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== - "@types/jsdom@^21.1.4": version "21.1.6" resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-21.1.6.tgz#bcbc7b245787ea863f3da1ef19aa1dcfb9271a1b" @@ -4134,7 +4188,7 @@ dependencies: "@types/react" "*" -"@types/react-dom@*", "@types/react-dom@^18.0.0", "@types/react-dom@^18.2.18": +"@types/react-dom@*", "@types/react-dom@^18.2.18": version "18.2.19" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.19.tgz#b84b7c30c635a6c26c6a6dfbb599b2da9788be58" integrity sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA== @@ -4501,24 +4555,24 @@ dependencies: "@swc/core" "^1.3.107" -"@vitest/coverage-v8@^1.0.4": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-1.2.2.tgz#681f4f76de896d0d2484cca32285477e288fec3a" - integrity sha512-IHyKnDz18SFclIEEAHb9Y4Uxx0sPKC2VO1kdDCs1BF6Ip4S8rQprs971zIsooLUn7Afs71GRxWMWpkCGZpRMhw== +"@vitest/coverage-v8@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-1.6.0.tgz#2f54ccf4c2d9f23a71294aba7f95b3d2e27d14e7" + integrity sha512-KvapcbMY/8GYIG0rlwwOKCVNRc0OL20rrhFkg/CHNzncV03TE2XWvO5w9uZYoxNiMEBacAJt3unSOiZ7svePew== dependencies: "@ampproject/remapping" "^2.2.1" "@bcoe/v8-coverage" "^0.2.3" debug "^4.3.4" istanbul-lib-coverage "^3.2.2" istanbul-lib-report "^3.0.1" - istanbul-lib-source-maps "^4.0.1" + istanbul-lib-source-maps "^5.0.4" istanbul-reports "^3.1.6" magic-string "^0.30.5" magicast "^0.3.3" picocolors "^1.0.0" std-env "^3.5.0" + strip-literal "^2.0.0" test-exclude "^6.0.0" - v8-to-istanbul "^9.2.0" "@vitest/expect@1.6.0": version "1.6.0" @@ -4554,12 +4608,12 @@ dependencies: tinyspy "^2.2.0" -"@vitest/ui@^1.0.4": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@vitest/ui/-/ui-1.2.2.tgz#62dddb1ec12bdc5c186e7f2425490bb8b5080695" - integrity sha512-CG+5fa8lyoBr+9i+UZGS31Qw81v33QlD10uecHxN2CLJVN+jLnqx4pGzGvFFeJ7jSnUCT0AlbmVWY6fU6NJZmw== +"@vitest/ui@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@vitest/ui/-/ui-1.6.0.tgz#ffcc97ebcceca7fec840c29ab68632d0cd01db93" + integrity sha512-k3Lyo+ONLOgylctiGovRKy7V4+dIN2yxstX3eY5cWFXH6WP+ooVX79YSyi0GagdTQzLmT43BF27T0s6dOIPBXA== dependencies: - "@vitest/utils" "1.2.2" + "@vitest/utils" "1.6.0" fast-glob "^3.3.2" fflate "^0.8.1" flatted "^3.2.9" @@ -4567,16 +4621,6 @@ picocolors "^1.0.0" sirv "^2.0.4" -"@vitest/utils@1.2.2": - version "1.2.2" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-1.2.2.tgz#94b5a1bd8745ac28cf220a99a8719efea1bcfc83" - integrity sha512-WKITBHLsBHlpjnDQahr+XK6RE7MiAsgrIkr0pGhQ9ygoxBfUeG0lUG5iLlzqjmKSlBv3+j5EGsriBzh+C3Tq9g== - dependencies: - diff-sequences "^29.6.3" - estree-walker "^3.0.3" - loupe "^2.3.7" - pretty-format "^29.7.0" - "@vitest/utils@1.6.0": version "1.6.0" resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-1.6.0.tgz#5c5675ca7d6f546a7b4337de9ae882e6c57896a1" @@ -4845,13 +4889,6 @@ aria-hidden@^1.1.1: dependencies: tslib "^2.0.0" -aria-query@5.1.3: - version "5.1.3" - resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.1.3.tgz#19db27cd101152773631396f7a95a3b58c22c35e" - integrity sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ== - dependencies: - deep-equal "^2.0.5" - aria-query@5.3.0, aria-query@^5.0.0, aria-query@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" @@ -6291,30 +6328,6 @@ deep-eql@^4.1.3: dependencies: type-detect "^4.0.0" -deep-equal@^2.0.5: - version "2.2.3" - resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.3.tgz#af89dafb23a396c7da3e862abc0be27cf51d56e1" - integrity sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA== - dependencies: - array-buffer-byte-length "^1.0.0" - call-bind "^1.0.5" - es-get-iterator "^1.1.3" - get-intrinsic "^1.2.2" - is-arguments "^1.1.1" - is-array-buffer "^3.0.2" - is-date-object "^1.0.5" - is-regex "^1.1.4" - is-shared-array-buffer "^1.0.2" - isarray "^2.0.5" - object-is "^1.1.5" - object-keys "^1.1.1" - object.assign "^4.1.4" - regexp.prototype.flags "^1.5.1" - side-channel "^1.0.4" - which-boxed-primitive "^1.0.2" - which-collection "^1.0.1" - which-typed-array "^1.1.13" - deep-extend@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" @@ -6681,21 +6694,6 @@ es-errors@^1.0.0, es-errors@^1.1.0, es-errors@^1.2.1, es-errors@^1.3.0: resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== -es-get-iterator@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6" - integrity sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw== - dependencies: - call-bind "^1.0.2" - get-intrinsic "^1.1.3" - has-symbols "^1.0.3" - is-arguments "^1.1.1" - is-map "^2.0.2" - is-set "^2.0.2" - is-string "^1.0.7" - isarray "^2.0.5" - stop-iteration-iterator "^1.0.0" - es-iterator-helpers@^1.0.12, es-iterator-helpers@^1.0.15: version "1.0.15" resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz#bd81d275ac766431d19305923707c3efd9f1ae40" @@ -8380,7 +8378,7 @@ inquirer@^7.0.0: strip-ansi "^6.0.0" through "^2.3.6" -internal-slot@^1.0.4, internal-slot@^1.0.5: +internal-slot@^1.0.5: version "1.0.7" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802" integrity sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g== @@ -8426,7 +8424,7 @@ is-absolute-url@^4.0.0: resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-4.0.1.tgz#16e4d487d4fded05cfe0685e53ec86804a5e94dc" integrity sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A== -is-arguments@^1.0.4, is-arguments@^1.1.1: +is-arguments@^1.0.4: version "1.1.1" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== @@ -8593,7 +8591,7 @@ is-interactive@^1.0.0: resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== -is-map@^2.0.1, is-map@^2.0.2: +is-map@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.2.tgz#00922db8c9bf73e81b7a335827bc2a43f2b91127" integrity sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg== @@ -8673,7 +8671,7 @@ is-regex@^1.1.4: call-bind "^1.0.2" has-tostringtag "^1.0.0" -is-set@^2.0.1, is-set@^2.0.2: +is-set@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.2.tgz#90755fa4c2562dc1c5d4024760d6119b94ca18ec" integrity sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g== @@ -8815,14 +8813,14 @@ istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1: make-dir "^4.0.0" supports-color "^7.1.0" -istanbul-lib-source-maps@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" - integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== +istanbul-lib-source-maps@^5.0.4: + version "5.0.4" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.4.tgz#1947003c72a91b6310efeb92d2a91be8804d92c2" + integrity sha512-wHOoEsNJTVltaJp8eVkm8w+GVkVNHT2YDYo53YdzQEL2gWm1hBX5cGFR9hQJtuGLebidVX7et3+dmDZrmclduw== dependencies: + "@jridgewell/trace-mapping" "^0.3.23" debug "^4.1.1" istanbul-lib-coverage "^3.0.0" - source-map "^0.6.1" istanbul-reports@^3.1.6: version "3.1.6" @@ -9415,13 +9413,6 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" -lru-cache@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" - integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== - dependencies: - yallist "^4.0.0" - lru-cache@^7.5.1: version "7.18.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" @@ -12036,11 +12027,9 @@ semver-compare@^1.0.0: integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== "semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3: - version "7.6.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" - integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== - dependencies: - lru-cache "^6.0.0" + version "7.6.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" + integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== send@0.18.0: version "0.18.0" @@ -12386,13 +12375,6 @@ std-env@^3.5.0: resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.7.0.tgz#c9f7386ced6ecf13360b6c6c55b8aaa4ef7481d2" integrity sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg== -stop-iteration-iterator@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz#6a60be0b4ee757d1ed5254858ec66b10c49285e4" - integrity sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ== - dependencies: - internal-slot "^1.0.4" - store2@^2.14.2: version "2.14.2" resolved "https://registry.yarnpkg.com/store2/-/store2-2.14.2.tgz#56138d200f9fe5f582ad63bc2704dbc0e4a45068" @@ -13176,9 +13158,9 @@ typescript@^4.9.5: integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== ua-parser-js@^0.7.30, ua-parser-js@^0.7.33: - version "0.7.37" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.37.tgz#e464e66dac2d33a7a1251d7d7a99d6157ec27832" - integrity sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA== + version "0.7.38" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.38.tgz#f497d8a4dc1fec6e854e5caa4b2f9913422ef054" + integrity sha512-fYmIy7fKTSFAhG3fuPlubeGaMoAd6r0rSnfEsO5nEY55i26KSLt9EH7PLQiiqPUhNqYIJvSkTy1oArIcXAbPbA== uc.micro@^1.0.1, uc.micro@^1.0.5: version "1.0.6" @@ -13423,15 +13405,6 @@ v8-compile-cache@^2.0.3: resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz#cdada8bec61e15865f05d097c5f4fd30e94dc128" integrity sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw== -v8-to-istanbul@^9.2.0: - version "9.2.0" - resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz#2ed7644a245cddd83d4e087b9b33b3e62dfd10ad" - integrity sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA== - dependencies: - "@jridgewell/trace-mapping" "^0.3.12" - "@types/istanbul-lib-coverage" "^2.0.1" - convert-source-map "^2.0.0" - validate-npm-package-license@^3.0.1: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" @@ -13527,7 +13500,7 @@ vite@^5.0.0, vite@^5.1.7: optionalDependencies: fsevents "~2.3.3" -vitest@^1.3.1: +vitest@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/vitest/-/vitest-1.6.0.tgz#9d5ad4752a3c451be919e412c597126cffb9892f" integrity sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA== @@ -13863,9 +13836,9 @@ yallist@^4.0.0: integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== yaml@2.3.1, yaml@^1.10.0, yaml@^1.7.2, yaml@^2.2.2, yaml@^2.3.0, yaml@^2.3.4: - version "2.4.1" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.1.tgz#2e57e0b5e995292c25c75d2658f0664765210eed" - integrity sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg== + version "2.4.5" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.4.5.tgz#60630b206dd6d84df97003d33fc1ddf6296cca5e" + integrity sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg== yargs-parser@^11.1.1, yargs-parser@^21.1.1: version "21.1.1" From b48ea06d914e8fe4e85eb4c5fc92a3118d75ccf0 Mon Sep 17 00:00:00 2001 From: Hana Xu Date: Tue, 11 Jun 2024 15:41:37 -0400 Subject: [PATCH 072/163] fix PlansPanel test --- .../src/features/components/PlansPanel/PlansPanel.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.test.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.test.tsx index 798101be7f0..71fa6a84919 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.test.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.test.tsx @@ -28,6 +28,10 @@ const defaultProps: PlansPanelProps = { ...planSelectionTypeFactory.build(), class: 'gpu', }, + { + ...planSelectionTypeFactory.build(), + class: 'premium', + }, ], }; From 2692d927b6e0d4f19e938a4c7c680677a8c60e05 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Tue, 11 Jun 2024 16:42:03 -0400 Subject: [PATCH 073/163] =?UTF-8?q?upcoming:=20[M3-8031]=20=E2=80=93=20Add?= =?UTF-8?q?=20Disk=20Encryption=20info=20banner=20to=20Kubernetes=20landin?= =?UTF-8?q?g=20page=20(#10546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pr-10546-tests-1717604850412.md | 5 ++ ...r-10546-upcoming-features-1717603180090.md | 5 ++ .../core/kubernetes/lke-landing-page.spec.ts | 60 ++++++++++++++++++- .../components/DiskEncryption/constants.tsx | 28 +++++++-- .../KubernetesLanding/KubernetesLanding.tsx | 22 +++++++ 5 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-10546-tests-1717604850412.md create mode 100644 packages/manager/.changeset/pr-10546-upcoming-features-1717603180090.md diff --git a/packages/manager/.changeset/pr-10546-tests-1717604850412.md b/packages/manager/.changeset/pr-10546-tests-1717604850412.md new file mode 100644 index 00000000000..b27e1dccd3e --- /dev/null +++ b/packages/manager/.changeset/pr-10546-tests-1717604850412.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add assertions regarding Disk Encryption info banner to lke-landing-page.spec.ts ([#10546](https://github.com/linode/manager/pull/10546)) diff --git a/packages/manager/.changeset/pr-10546-upcoming-features-1717603180090.md b/packages/manager/.changeset/pr-10546-upcoming-features-1717603180090.md new file mode 100644 index 00000000000..fe944b48194 --- /dev/null +++ b/packages/manager/.changeset/pr-10546-upcoming-features-1717603180090.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Disk Encryption info banner to Kubernetes landing page ([#10546](https://github.com/linode/manager/pull/10546)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts index a45cb165cb3..734d807b6c8 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts @@ -4,12 +4,70 @@ import { mockGetClusterPools, mockGetKubeconfig, } from 'support/intercepts/lke'; -import { kubernetesClusterFactory, nodePoolFactory } from 'src/factories'; +import { + accountFactory, + kubernetesClusterFactory, + nodePoolFactory, +} from 'src/factories'; import { getRegionById } from 'support/util/regions'; import { readDownload } from 'support/util/downloads'; import { ui } from 'support/ui'; +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockGetAccount } from 'support/intercepts/account'; describe('LKE landing page', () => { + it('does not display a Disk Encryption info banner if the LDE feature is disabled', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(false), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Mock responses + const mockAccount = accountFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + mockGetAccount(mockAccount).as('getAccount'); + + // Intercept request + cy.visitWithLogin('/kubernetes/clusters'); + cy.wait('@getAccount'); + + // Check if banner is visible + cy.findByText('Disk encryption is now standard on Linodes.').should( + 'not.exist' + ); + }); + + it('displays a Disk Encryption info banner if the LDE feature is enabled', () => { + // Mock feature flag -- @TODO LDE: Remove feature flag once LDE is fully rolled out + mockAppendFeatureFlags({ + linodeDiskEncryption: makeFeatureFlagData(true), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + // Mock responses + const mockAccount = accountFactory.build({ + capabilities: ['Linodes', 'Disk Encryption'], + }); + + mockGetAccount(mockAccount).as('getAccount'); + + // Intercept request + cy.visitWithLogin('/kubernetes/clusters'); + cy.wait('@getAccount'); + + // Check if banner is visible + cy.contains('Disk encryption is now standard on Linodes.').should( + 'be.visible' + ); + }); + /* * - Confirms that LKE clusters are listed on landing page. */ diff --git a/packages/manager/src/components/DiskEncryption/constants.tsx b/packages/manager/src/components/DiskEncryption/constants.tsx index 539b19d3a6c..a49b31115c5 100644 --- a/packages/manager/src/components/DiskEncryption/constants.tsx +++ b/packages/manager/src/components/DiskEncryption/constants.tsx @@ -11,20 +11,38 @@ export const DISK_ENCRYPTION_GENERAL_DESCRIPTION = ( ); -export const DISK_ENCRYPTION_DESCRIPTION_NODE_POOL_REBUILD_CAVEAT = - 'Encrypt Linode data at rest to improve security. The disk encryption setting for Linodes added to a node pool will not be changed after rebuild.'; +const DISK_ENCRYPTION_UPDATE_PROTECT_CLUSTERS_DOCS_LINK = + 'https://www.linode.com/docs/products/compute/compute-instances/guides/local-disk-encryption/'; + +export const DISK_ENCRYPTION_UPDATE_PROTECT_CLUSTERS_COPY = ( + <> + Disk encryption is now standard on Linodes.{' '} + + Learn how + {' '} + to update and protect your clusters. + +); + +export const DISK_ENCRYPTION_UPDATE_PROTECT_CLUSTERS_BANNER_KEY = + 'disk-encryption-update-protect-clusters-banner'; export const DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY = 'Disk encryption is not available in the selected region.'; -export const DISK_ENCRYPTION_BACKUPS_CAVEAT_COPY = - 'Virtual Machine Backups are not encrypted.'; - +// Guidance export const DISK_ENCRYPTION_NODE_POOL_GUIDANCE_COPY = 'To enable disk encryption, delete the node pool and create a new node pool. New node pools are always encrypted.'; export const UNENCRYPTED_STANDARD_LINODE_GUIDANCE_COPY = 'Rebuild this Linode to enable or disable disk encryption.'; +// Caveats +export const DISK_ENCRYPTION_DESCRIPTION_NODE_POOL_REBUILD_CAVEAT = + 'Encrypt Linode data at rest to improve security. The disk encryption setting for Linodes added to a node pool will not be changed after rebuild.'; + +export const DISK_ENCRYPTION_BACKUPS_CAVEAT_COPY = + 'Virtual Machine Backups are not encrypted.'; + export const DISK_ENCRYPTION_IMAGES_CAVEAT_COPY = 'Virtual Machine Images are not encrypted.'; diff --git a/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx b/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx index 086327952c6..3477800a7bb 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesLanding/KubernetesLanding.tsx @@ -3,6 +3,12 @@ import * as React from 'react'; import { useHistory } from 'react-router-dom'; import { CircleProgress } from 'src/components/CircleProgress'; +import { + DISK_ENCRYPTION_UPDATE_PROTECT_CLUSTERS_BANNER_KEY, + DISK_ENCRYPTION_UPDATE_PROTECT_CLUSTERS_COPY, +} from 'src/components/DiskEncryption/constants'; +import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; +import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Hidden } from 'src/components/Hidden'; @@ -15,6 +21,7 @@ import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; +import { Typography } from 'src/components/Typography'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useKubernetesClustersQuery } from 'src/queries/kubernetes'; @@ -92,6 +99,10 @@ export const KubernetesLanding = () => { filter ); + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); + const openUpgradeDialog = ( clusterID: number, clusterLabel: string, @@ -149,6 +160,17 @@ export const KubernetesLanding = () => { return ( <> + {isDiskEncryptionFeatureEnabled && ( + + + {DISK_ENCRYPTION_UPDATE_PROTECT_CLUSTERS_COPY} + + + )} Date: Tue, 11 Jun 2024 17:25:36 -0400 Subject: [PATCH 074/163] fix kubernetes plan panel --- .../KubernetesPlansPanel/KubernetesPlansPanel.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx index fffc1efc25b..2dc8bc961ae 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesPlansPanel/KubernetesPlansPanel.tsx @@ -11,13 +11,13 @@ import { } from 'src/features/components/PlansPanel/utils'; import { useFlags } from 'src/hooks/useFlags'; import { useRegionAvailabilityQuery } from 'src/queries/regions/regions'; -import { ExtendedType } from 'src/utilities/extendType'; import { KubernetesPlanContainer } from './KubernetesPlanContainer'; import type { CreateNodePoolData, Region } from '@linode/api-v4'; import type { LinodeTypeClass } from '@linode/api-v4/lib/linodes/types'; import type { PlanSelectionType } from 'src/features/components/PlansPanel/types'; +import type { ExtendedType } from 'src/utilities/extendType'; interface Props { addPool?: (pool?: CreateNodePoolData) => void; @@ -67,9 +67,15 @@ export const KubernetesPlansPanel = (props: Props) => { Boolean(flags.soldOutChips) && selectedRegionId !== undefined ); - const _types = replaceOrAppendPlaceholder512GbPlans(types); + const _types = types.filter( + (type) => + !type.id.includes('dedicated-edge') && !type.id.includes('nanode-edge') + ); + const plans = getPlanSelectionsByPlanType( - flags.disableLargestGbPlans ? _types : types + flags.disableLargestGbPlans + ? replaceOrAppendPlaceholder512GbPlans(_types) + : _types ); const tabs = Object.keys(plans).map((plan: LinodeTypeClass) => { From c9a970ea49def2e9f1efc390823d8ab2870e13e2 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Wed, 12 Jun 2024 09:08:43 -0400 Subject: [PATCH 075/163] test: [M3-8227] - Prevent Cypress failures when `cy.visit` hits non-200 response (#10562) * Prevent Cypress failures when `cy.visit` request responds with bad status code * Added changeset: Improve Cypress test suite compatibility against alternative environments --- packages/manager/.changeset/pr-10562-tests-1718047447893.md | 5 +++++ packages/manager/cypress/support/setup/login-command.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 packages/manager/.changeset/pr-10562-tests-1718047447893.md diff --git a/packages/manager/.changeset/pr-10562-tests-1718047447893.md b/packages/manager/.changeset/pr-10562-tests-1718047447893.md new file mode 100644 index 00000000000..99142c23e77 --- /dev/null +++ b/packages/manager/.changeset/pr-10562-tests-1718047447893.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Improve Cypress test suite compatibility against alternative environments ([#10562](https://github.com/linode/manager/pull/10562)) diff --git a/packages/manager/cypress/support/setup/login-command.ts b/packages/manager/cypress/support/setup/login-command.ts index a3ed2289b37..35b6a151eb4 100644 --- a/packages/manager/cypress/support/setup/login-command.ts +++ b/packages/manager/cypress/support/setup/login-command.ts @@ -75,6 +75,7 @@ Cypress.Commands.add( ); } }, + failOnStatusCode: false, }; if (resolvedLinodeOptions.preferenceOverrides) { From 556877d824a9d4d902ed66e5bb13d02879c82333 Mon Sep 17 00:00:00 2001 From: carrillo-erik <119514965+carrillo-erik@users.noreply.github.com> Date: Wed, 12 Jun 2024 07:45:36 -0700 Subject: [PATCH 076/163] test: [M3-7769] - Add Placement Groups Navigation Integration Tests (#10552) * Create integration tests for placement group navigation * Add changeset * Add user account mocks to integration tests --- .../pr-10552-tests-1717702940797.md | 5 ++ .../placement-groups-navigation.spec.ts | 82 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 packages/manager/.changeset/pr-10552-tests-1717702940797.md create mode 100644 packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts diff --git a/packages/manager/.changeset/pr-10552-tests-1717702940797.md b/packages/manager/.changeset/pr-10552-tests-1717702940797.md new file mode 100644 index 00000000000..9c353c069e2 --- /dev/null +++ b/packages/manager/.changeset/pr-10552-tests-1717702940797.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Placement Group navigation integration tests ([#10552](https://github.com/linode/manager/pull/10552)) diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts new file mode 100644 index 00000000000..4e1d4b0a686 --- /dev/null +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts @@ -0,0 +1,82 @@ +/** + * @file Integration tests for Placement Groups navigation. + */ + +import { + mockAppendFeatureFlags, + mockGetFeatureFlagClientstream, +} from 'support/intercepts/feature-flags'; +import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { mockGetAccount } from 'support/intercepts/account'; +import { accountFactory } from 'src/factories'; +import { ui } from 'support/ui'; + +import type { Flags } from 'src/featureFlags'; + +const mockAccount = accountFactory.build(); + +describe('Placement Groups navigation', () => { + // Mock User Account to include Placement Group capability + beforeEach(() => { + mockGetAccount(mockAccount).as('getAccount'); + }); + + /* + * - Confirms that Placement Groups navigation item is shown when feature flag is enabled. + * - Confirms that clicking Placement Groups navigation item directs user to Placement Groups landing page. + */ + it('can navigate to Placement Groups landing page', () => { + mockAppendFeatureFlags({ + placementGroups: makeFeatureFlagData({ + beta: true, + enabled: true, + }), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + cy.visitWithLogin('/linodes'); + cy.wait(['@getFeatureFlags', '@getClientStream']); + + ui.nav.findItemByTitle('Placement Groups').should('be.visible').click(); + + cy.url().should('endWith', '/placement-groups'); + }); + + /* + * - Confirms that Placement Groups navigation item is not shown when feature flag is disabled. + */ + it('does not show Placement Groups navigation item when feature is disabled', () => { + mockAppendFeatureFlags({ + placementGroups: makeFeatureFlagData({ + beta: true, + enabled: false, + }), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + cy.visitWithLogin('/linodes'); + cy.wait(['@getFeatureFlags', '@getClientStream']); + + ui.nav.find().within(() => { + cy.findByText('Placement Groups').should('not.exist'); + }); + }); + + /* + * - Confirms that manual navigation to Placement Groups landing page with feature is disabled displays Not Found to user. + */ + it('displays Not Found when manually navigating to /placement-groups with feature flag disabled', () => { + mockAppendFeatureFlags({ + placementGroups: makeFeatureFlagData({ + beta: true, + enabled: false, + }), + }).as('getFeatureFlags'); + mockGetFeatureFlagClientstream().as('getClientStream'); + + cy.visitWithLogin('/placement-groups'); + cy.wait(['@getFeatureFlags', '@getClientStream']); + + cy.findByText('Not Found').should('be.visible'); + }); +}); From 230b623e7eab2fb916bbe2c68b87c77e034980c5 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Wed, 12 Jun 2024 11:44:14 -0400 Subject: [PATCH 077/163] test: [M3-7667] - (Proof of Concept) Add tagging capability for Cypress tests (#10475) * Make `cy.defer` accept a Promise generator function instead of a Promise * Slight improvements to test tag info output during Cypress start up * Add Cypress test tagging capability --- docs/development-guide/08-testing.md | 7 +- packages/manager/cypress.config.ts | 2 + .../e2e/core/account/service-transfer.spec.ts | 4 +- .../account/third-party-access-tokens.spec.ts | 2 +- .../billing/smoke-billing-activity.spec.ts | 2 +- .../core/domains/smoke-clone-domain.spec.ts | 2 +- .../core/domains/smoke-delete-domain.spec.ts | 2 +- .../core/firewalls/create-firewall.spec.ts | 2 +- .../core/firewalls/delete-firewall.spec.ts | 2 +- .../migrate-linode-with-firewall.spec.ts | 2 +- .../core/firewalls/update-firewall.spec.ts | 6 +- .../e2e/core/images/create-image.spec.ts | 2 +- .../e2e/core/linodes/backup-linode.spec.ts | 210 +++++++++--------- .../e2e/core/linodes/clone-linode.spec.ts | 2 +- .../e2e/core/linodes/linode-config.spec.ts | 129 ++++++----- .../e2e/core/linodes/linode-storage.spec.ts | 8 +- .../e2e/core/linodes/rebuild-linode.spec.ts | 168 +++++++------- .../e2e/core/linodes/rescue-linode.spec.ts | 2 +- .../e2e/core/linodes/resize-linode.spec.ts | 160 +++++++------ .../core/linodes/smoke-delete-linode.spec.ts | 8 +- .../core/linodes/switch-linode-state.spec.ts | 116 +++++----- .../core/linodes/update-linode-labels.spec.ts | 4 +- .../e2e/core/longview/longview.spec.ts | 2 +- .../smoke-create-nodebal.spec.ts | 6 +- .../core/objectStorage/access-key.e2e.spec.ts | 2 +- .../objectStorage/object-storage.e2e.spec.ts | 4 +- .../stackscripts/create-stackscripts.spec.ts | 2 +- .../smoke-community-stackscrips.spec.ts | 2 +- .../e2e/core/volumes/attach-volume.spec.ts | 2 +- .../e2e/core/volumes/clone-volume.spec.ts | 2 +- .../e2e/core/volumes/create-volume.spec.ts | 4 +- .../e2e/core/volumes/delete-volume.spec.ts | 2 +- .../e2e/core/volumes/resize-volume.spec.ts | 2 +- .../e2e/core/volumes/update-volume.spec.ts | 2 +- .../create-machine-image-from-linode.spec.ts | 2 +- .../update-delete-machine-image.spec.ts | 2 +- .../e2e/region/linodes/delete-linode.spec.ts | 2 +- .../e2e/region/linodes/update-linode.spec.ts | 4 +- packages/manager/cypress/support/e2e.ts | 2 + packages/manager/cypress/support/index.d.ts | 30 ++- .../support/plugins/test-tagging-info.ts | 35 +++ .../cypress/support/setup/defer-command.ts | 4 +- .../cypress/support/setup/test-tagging.ts | 41 ++++ .../manager/cypress/support/util/arrays.ts | 11 + .../manager/cypress/support/util/cleanup.ts | 4 +- packages/manager/cypress/support/util/tag.ts | 170 ++++++++++++++ 46 files changed, 740 insertions(+), 441 deletions(-) create mode 100644 packages/manager/cypress/support/plugins/test-tagging-info.ts create mode 100644 packages/manager/cypress/support/setup/test-tagging.ts create mode 100644 packages/manager/cypress/support/util/tag.ts diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index caf87e86060..febe5b53519 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -186,9 +186,10 @@ These environment variables are specific to Cloud Manager UI tests. They can be ###### General Environment variables related to the general operation of the Cloud Manager Cypress tests. -| Environment Variable | Description | Example | Default | -|----------------------|-------------------------------------------------------------------------------------------------------|----------|---------------------------------| -| `CY_TEST_SUITE` | Name of the Cloud Manager UI test suite to run. Possible values are `core`, `region`, or `synthetic`. | `region` | Unset; defaults to `core` suite | +| Environment Variable | Description | Example | Default | +|----------------------|-------------------------------------------------------------------------------------------------------|--------------|---------------------------------| +| `CY_TEST_SUITE` | Name of the Cloud Manager UI test suite to run. Possible values are `core`, `region`, or `synthetic`. | `region` | Unset; defaults to `core` suite | +| `CY_TEST_TAGS` | Query identifying tests that should run by specifying allowed and disallowed tags. | `method:e2e` | Unset; all tests run by default | ###### Regions These environment variables are used by Cloud Manager's UI tests to override region selection behavior. This can be useful for testing Cloud Manager functionality against a specific region. diff --git a/packages/manager/cypress.config.ts b/packages/manager/cypress.config.ts index ff201ae504e..1c7efb41af8 100644 --- a/packages/manager/cypress.config.ts +++ b/packages/manager/cypress.config.ts @@ -14,6 +14,7 @@ import { fetchAccount } from './cypress/support/plugins/fetch-account'; import { fetchLinodeRegions } from './cypress/support/plugins/fetch-linode-regions'; import { splitCypressRun } from './cypress/support/plugins/split-run'; import { enableJunitReport } from './cypress/support/plugins/junit-report'; +import { logTestTagInfo } from './cypress/support/plugins/test-tagging-info'; /** * Exports a Cypress configuration object. @@ -66,6 +67,7 @@ export default defineConfig({ fetchAccount, fetchLinodeRegions, regionOverrideCheck, + logTestTagInfo, splitCypressRun, enableJunitReport, ]); diff --git a/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts b/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts index 9268d097c88..71428f889ea 100644 --- a/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts +++ b/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts @@ -175,7 +175,7 @@ describe('Account service transfers', () => { cy.wait(['@getTransfers', '@getTransfers', '@getTransfers']); // Confirm that pending transfers are displayed in "Pending Service Transfers" panel. - cy.defer(getProfile(), 'getting profile').then((profile: Profile) => { + cy.defer(() => getProfile(), 'getting profile').then((profile: Profile) => { const dateFormatOptions = { timezone: profile.timezone }; cy.get('[data-qa-panel="Pending Service Transfers"]') .should('be.visible') @@ -262,7 +262,7 @@ describe('Account service transfers', () => { return linode; }; - cy.defer(setupLinode(), 'creating and booting Linode').then( + cy.defer(() => setupLinode(), 'creating and booting Linode').then( (linode: Linode) => { interceptInitiateEntityTransfer().as('initiateTransfer'); diff --git a/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts b/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts index 153f8c85458..9e918bf9dcd 100644 --- a/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts +++ b/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts @@ -38,7 +38,7 @@ describe('Third party access tokens', () => { .closest('tr') .within(() => { cy.findByText(token.label).should('be.visible'); - cy.defer(getProfile()).then((profile: Profile) => { + cy.defer(() => getProfile()).then((profile: Profile) => { const dateFormatOptions = { timezone: profile.timezone }; cy.findByText(formatDate(token.created, dateFormatOptions)).should( 'be.visible' diff --git a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts index f130dae258c..1cdcbcb1b6c 100644 --- a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts @@ -162,7 +162,7 @@ describe('Billing Activity Feed', () => { mockGetPayments(paymentMocks6Months).as('getPayments'); mockGetPaymentMethods([]); - cy.defer(getProfile()).then((profile: Profile) => { + cy.defer(() => getProfile()).then((profile: Profile) => { const timezone = profile.timezone; cy.visitWithLogin('/account/billing'); cy.wait(['@getInvoices', '@getPayments']); diff --git a/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts index f85218804e7..d45a2bf886d 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts @@ -34,7 +34,7 @@ describe('Clone a Domain', () => { const domainRecords = createDomainRecords(); - cy.defer(createDomain(domainRequest), 'creating domain').then( + cy.defer(() => createDomain(domainRequest), 'creating domain').then( (domain: Domain) => { // Add records to the domain. cy.visitWithLogin(`/domains/${domain.id}`); diff --git a/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts index 6a992e26b70..80d9b632aa2 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts @@ -20,7 +20,7 @@ describe('Delete a Domain', () => { group: 'test-group', }); - cy.defer(createDomain(domainRequest), 'creating domain').then( + cy.defer(() => createDomain(domainRequest), 'creating domain').then( (domain: Domain) => { cy.visitWithLogin('/domains'); diff --git a/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts index 298417a1a72..4da8d8c2dab 100644 --- a/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts @@ -75,7 +75,7 @@ describe('create firewall', () => { }; cy.defer( - createTestLinode(linodeRequest, { securityMethod: 'powered_off' }), + () => createTestLinode(linodeRequest, { securityMethod: 'powered_off' }), 'creating Linode' ).then((linode) => { interceptCreateFirewall().as('createFirewall'); diff --git a/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts index 323a42cd398..2cbedb29e5f 100644 --- a/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts @@ -23,7 +23,7 @@ describe('delete firewall', () => { label: randomLabel(), }); - cy.defer(createFirewall(firewallRequest), 'creating firewalls').then( + cy.defer(() => createFirewall(firewallRequest), 'creating firewalls').then( (firewall: Firewall) => { cy.visitWithLogin('/firewalls'); diff --git a/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts index 2ff4bd31f67..67b940c3b1c 100644 --- a/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts @@ -144,7 +144,7 @@ describe('Migrate Linode With Firewall', () => { interceptGetFirewalls().as('getFirewalls'); // Create a Linode, then navigate to the Firewalls landing page. - cy.defer( + cy.defer(() => createTestLinode(linodePayload, { securityMethod: 'powered_off' }) ).then((linode: Linode) => { interceptMigrateLinode(linode.id).as('migrateLinode'); diff --git a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts index dbbde69165f..9ca48ad7fe0 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -196,7 +196,7 @@ describe('update firewall', () => { }); cy.defer( - createLinodeAndFirewall(linodeRequest, firewallRequest), + () => createLinodeAndFirewall(linodeRequest, firewallRequest), 'creating Linode and firewall' ).then(([linode, firewall]) => { cy.visitWithLogin('/firewalls'); @@ -324,7 +324,7 @@ describe('update firewall', () => { }); cy.defer( - createLinodeAndFirewall(linodeRequest, firewallRequest), + () => createLinodeAndFirewall(linodeRequest, firewallRequest), 'creating Linode and firewall' ).then(([_linode, firewall]) => { cy.visitWithLogin('/firewalls'); @@ -420,7 +420,7 @@ describe('update firewall', () => { const newFirewallLabel = randomLabel(); cy.defer( - createLinodeAndFirewall(linodeRequest, firewallRequest), + () => createLinodeAndFirewall(linodeRequest, firewallRequest), 'creating Linode and firewall' ).then(([_linode, firewall]) => { cy.visitWithLogin('/firewalls'); diff --git a/packages/manager/cypress/e2e/core/images/create-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-image.spec.ts index fdffbf581d4..15782fb004e 100644 --- a/packages/manager/cypress/e2e/core/images/create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-image.spec.ts @@ -61,7 +61,7 @@ describe('create image (e2e)', () => { const disk = 'Alpine 3.19 Disk'; cy.defer( - createTestLinode({ image }, { waitForDisks: true }), + () => createTestLinode({ image }, { waitForDisks: true }), 'create linode' ).then((linode: Linode) => { cy.visitWithLogin('/images/create'); diff --git a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts index 4fd976daa48..f973a37651d 100644 --- a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts @@ -53,52 +53,53 @@ describe('linode backups', () => { booted: false, }); - cy.defer(createTestLinode(createLinodeRequest), 'creating Linode').then( - (linode: Linode) => { - interceptGetLinode(linode.id).as('getLinode'); - interceptEnableLinodeBackups(linode.id).as('enableBackups'); - - // Navigate to Linode details page "Backups" tab. - cy.visitWithLogin(`linodes/${linode.id}/backup`); - cy.wait('@getLinode'); - - // Wait for Linode to finish provisioning. - cy.findByText('OFFLINE').should('be.visible'); - - // Confirm that enable backups prompt is shown. - cy.contains( - 'Three backup slots are executed and rotated automatically' - ).should('be.visible'); - - ui.button - .findByTitle('Enable Backups') - .should('be.visible') - .should('be.enabled') - .click(); - - ui.dialog - .findByTitle('Enable backups?') - .should('be.visible') - .within(() => { - // Confirm that user is warned of additional backup charges. - cy.contains(/.* This will add .* to your monthly bill\./).should( - 'be.visible' - ); - ui.button - .findByTitle('Enable Backups') - .should('be.visible') - .should('be.enabled') - .click(); - }); + cy.defer( + () => createTestLinode(createLinodeRequest), + 'creating Linode' + ).then((linode: Linode) => { + interceptGetLinode(linode.id).as('getLinode'); + interceptEnableLinodeBackups(linode.id).as('enableBackups'); + + // Navigate to Linode details page "Backups" tab. + cy.visitWithLogin(`linodes/${linode.id}/backup`); + cy.wait('@getLinode'); + + // Wait for Linode to finish provisioning. + cy.findByText('OFFLINE').should('be.visible'); + + // Confirm that enable backups prompt is shown. + cy.contains( + 'Three backup slots are executed and rotated automatically' + ).should('be.visible'); + + ui.button + .findByTitle('Enable Backups') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.dialog + .findByTitle('Enable backups?') + .should('be.visible') + .within(() => { + // Confirm that user is warned of additional backup charges. + cy.contains(/.* This will add .* to your monthly bill\./).should( + 'be.visible' + ); + ui.button + .findByTitle('Enable Backups') + .should('be.visible') + .should('be.enabled') + .click(); + }); - // Confirm that toast notification appears and UI updates to reflect enabled backups. - cy.wait('@enableBackups'); - ui.toast.assertMessage('Backups are being enabled for this Linode.'); - cy.findByText( - 'Automatic and manual backups will be listed here' - ).should('be.visible'); - } - ); + // Confirm that toast notification appears and UI updates to reflect enabled backups. + cy.wait('@enableBackups'); + ui.toast.assertMessage('Backups are being enabled for this Linode.'); + cy.findByText('Automatic and manual backups will be listed here').should( + 'be.visible' + ); + }); }); /* @@ -116,71 +117,70 @@ describe('linode backups', () => { const snapshotName = randomLabel(); - cy.defer(createTestLinode(createLinodeRequest), 'creating Linode').then( - (linode: Linode) => { - interceptGetLinode(linode.id).as('getLinode'); - interceptCreateLinodeSnapshot(linode.id).as('createSnapshot'); - - // Navigate to Linode details page "Backups" tab. - cy.visitWithLogin(`/linodes/${linode.id}/backup`); - cy.wait('@getLinode'); + cy.defer( + () => createTestLinode(createLinodeRequest), + 'creating Linode' + ).then((linode: Linode) => { + interceptGetLinode(linode.id).as('getLinode'); + interceptCreateLinodeSnapshot(linode.id).as('createSnapshot'); + + // Navigate to Linode details page "Backups" tab. + cy.visitWithLogin(`/linodes/${linode.id}/backup`); + cy.wait('@getLinode'); + + // Wait for the Linode to finish provisioning. + cy.findByText('OFFLINE').should('be.visible'); + + cy.findByText('Manual Snapshot') + .should('be.visible') + .parent() + .within(() => { + // Confirm that "Take Snapshot" button is disabled until a name is entered. + ui.button + .findByTitle('Take Snapshot') + .should('be.visible') + .should('be.disabled'); - // Wait for the Linode to finish provisioning. - cy.findByText('OFFLINE').should('be.visible'); + // Enter a snapshot name, click "Take Snapshot". + cy.findByLabelText('Name Snapshot') + .should('be.visible') + .clear() + .type(snapshotName); - cy.findByText('Manual Snapshot') - .should('be.visible') - .parent() - .within(() => { - // Confirm that "Take Snapshot" button is disabled until a name is entered. - ui.button - .findByTitle('Take Snapshot') - .should('be.visible') - .should('be.disabled'); - - // Enter a snapshot name, click "Take Snapshot". - cy.findByLabelText('Name Snapshot') - .should('be.visible') - .clear() - .type(snapshotName); - - ui.button - .findByTitle('Take Snapshot') - .should('be.visible') - .should('be.enabled') - .click(); - }); + ui.button + .findByTitle('Take Snapshot') + .should('be.visible') + .should('be.enabled') + .click(); + }); - // Submit confirmation, confirm that toast message appears. - ui.dialog - .findByTitle('Take a snapshot?') - .should('be.visible') - .within(() => { - // Confirm user is warned that previous snapshot will be replaced. - cy.contains('overriding your previous snapshot').should( - 'be.visible' - ); - cy.contains('Are you sure?').should('be.visible'); - - ui.button - .findByTitle('Take Snapshot') - .should('be.visible') - .should('be.enabled') - .click(); - }); + // Submit confirmation, confirm that toast message appears. + ui.dialog + .findByTitle('Take a snapshot?') + .should('be.visible') + .within(() => { + // Confirm user is warned that previous snapshot will be replaced. + cy.contains('overriding your previous snapshot').should('be.visible'); + cy.contains('Are you sure?').should('be.visible'); + + ui.button + .findByTitle('Take Snapshot') + .should('be.visible') + .should('be.enabled') + .click(); + }); - cy.wait('@createSnapshot'); - ui.toast.assertMessage('Starting to capture snapshot'); + cy.wait('@createSnapshot'); + ui.toast.assertMessage('Starting to capture snapshot'); - // Confirm that new snapshot is listed in backups table. - cy.findByText(snapshotName) - .should('be.visible') - .closest('tr') - .within(() => { - cy.findByText('Pending').should('be.visible'); - }); - } - ); + // Confirm that new snapshot is listed in backups table. + cy.findByText(snapshotName) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Pending').should('be.visible'); + }); + }); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts index afc1c505ac4..5a664581264 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -56,7 +56,7 @@ describe('clone linode', () => { const newLinodeLabel = `${linodePayload.label}-clone`; - cy.defer(createTestLinode(linodePayload)).then((linode: Linode) => { + cy.defer(() => createTestLinode(linodePayload)).then((linode: Linode) => { const linodeRegion = getRegionById(linodePayload.region!); interceptCloneLinode(linode.id).as('cloneLinode'); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts index 81ec20da2d5..38f4ceea087 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -80,7 +80,7 @@ describe('Linode Config management', () => { // Fetch Linode kernel data from the API. // We'll use this data in the tests to confirm that config labels are rendered correctly. - cy.defer(fetchAllKernels(), 'Fetching Linode kernels...').then( + cy.defer(() => fetchAllKernels(), 'Fetching Linode kernels...').then( (fetchedKernels) => { kernels = fetchedKernels; } @@ -95,61 +95,69 @@ describe('Linode Config management', () => { */ it('Creates a config', () => { // Wait for Linode to be created for kernel data to be retrieved. - cy.defer(createTestLinode(), 'Creating Linode').then((linode: Linode) => { - interceptCreateLinodeConfigs(linode.id).as('postLinodeConfigs'); - interceptGetLinodeConfigs(linode.id).as('getLinodeConfigs'); - - cy.visitWithLogin(`/linodes/${linode.id}/configurations`); - - // Confirm that initial config is listed in Linode configurations table. - cy.wait('@getLinodeConfigs'); - cy.defer(fetchLinodeConfigs(linode.id)).then((configs: Config[]) => { - cy.findByLabelText('List of Configurations').within(() => { - configs.forEach((config) => { - const kernel = findKernelById(kernels, config.kernel); - cy.findByText(`${config.label} – ${kernel.label}`).should( - 'be.visible' - ); + cy.defer(() => createTestLinode(), 'Creating Linode').then( + (linode: Linode) => { + interceptCreateLinodeConfigs(linode.id).as('postLinodeConfigs'); + interceptGetLinodeConfigs(linode.id).as('getLinodeConfigs'); + + cy.visitWithLogin(`/linodes/${linode.id}/configurations`); + + // Confirm that initial config is listed in Linode configurations table. + cy.wait('@getLinodeConfigs'); + cy.defer(() => fetchLinodeConfigs(linode.id)).then( + (configs: Config[]) => { + cy.findByLabelText('List of Configurations').within(() => { + configs.forEach((config) => { + const kernel = findKernelById(kernels, config.kernel); + cy.findByText(`${config.label} – ${kernel.label}`).should( + 'be.visible' + ); + }); + }); + } + ); + + // Add new configuration. + cy.findByText('Add Configuration').click(); + ui.dialog + .findByTitle('Add Configuration') + .should('be.visible') + .within(() => { + cy.get('#label').type(`${linode.id}-test-config`); + ui.buttonGroup + .findButtonByTitle('Add Configuration') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); }); - }); - }); - // Add new configuration. - cy.findByText('Add Configuration').click(); - ui.dialog - .findByTitle('Add Configuration') - .should('be.visible') - .within(() => { - cy.get('#label').type(`${linode.id}-test-config`); - ui.buttonGroup - .findButtonByTitle('Add Configuration') - .scrollIntoView() - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Confirm that config creation request was successful. - cy.wait('@postLinodeConfigs') - .its('response.statusCode') - .should('eq', 200); - - // Confirm that new config and existing config are both listed. - cy.wait('@getLinodeConfigs'); - cy.defer(fetchLinodeConfigs(linode.id)).then((configs: Config[]) => { - cy.findByLabelText('List of Configurations').within(() => { - configs.forEach((config) => { - const kernel = findKernelById(kernels, config.kernel); - cy.findByText(`${config.label} – ${kernel.label}`) - .should('be.visible') - .closest('tr') - .within(() => { - cy.findByText('eth0 – Public Internet').should('be.visible'); + // Confirm that config creation request was successful. + cy.wait('@postLinodeConfigs') + .its('response.statusCode') + .should('eq', 200); + + // Confirm that new config and existing config are both listed. + cy.wait('@getLinodeConfigs'); + cy.defer(() => fetchLinodeConfigs(linode.id)).then( + (configs: Config[]) => { + cy.findByLabelText('List of Configurations').within(() => { + configs.forEach((config) => { + const kernel = findKernelById(kernels, config.kernel); + cy.findByText(`${config.label} – ${kernel.label}`) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('eth0 – Public Internet').should( + 'be.visible' + ); + }); }); - }); - }); - }); - }); + }); + } + ); + } + ); }); /** @@ -174,7 +182,7 @@ describe('Linode Config management', () => { // Create a Linode and wait for its Config to be fetched before proceeding. cy.defer( - createLinodeAndGetConfig({ interfaces }, { waitForDisks: true }), + () => createLinodeAndGetConfig({ interfaces }, { waitForDisks: true }), 'creating a linode and getting its config' ).then(([linode, config]: [Linode, Config]) => { // Get kernel info for config. @@ -234,10 +242,11 @@ describe('Linode Config management', () => { */ it('Boots a config', () => { cy.defer( - createLinodeAndGetConfig( - { booted: true }, - { waitForBoot: true, securityMethod: 'vlan_no_internet' } - ), + () => + createLinodeAndGetConfig( + { booted: true }, + { waitForBoot: true, securityMethod: 'vlan_no_internet' } + ), 'Creating and booting test Linode' ).then(([linode, config]: [Linode, Config]) => { const kernel = findKernelById(kernels, config.kernel); @@ -291,7 +300,7 @@ describe('Linode Config management', () => { // Create clone and source destination Linodes, then proceed with clone flow. cy.defer( - createCloneTestLinodes(), + () => createCloneTestLinodes(), 'Waiting for 2 Linodes to be created' ).then(([sourceLinode, destLinode]: [Linode, Linode]) => { const kernel = findKernelById(kernels, 'linode/latest-64bit'); @@ -370,7 +379,7 @@ describe('Linode Config management', () => { */ it('Deletes a config', () => { cy.defer( - createLinodeAndGetConfig(), + () => createLinodeAndGetConfig(), 'creating a linode and getting its config' ).then(([linode, config]: [Linode, Config]) => { // Get kernel info for config to be deleted. diff --git a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts index de3d7603a40..6cd87caaf14 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts @@ -104,7 +104,7 @@ describe('linode storage tab', () => { it('try to delete in use disk', () => { const diskName = 'Debian 11 Disk'; - cy.defer(createTestLinode({ booted: true })).then((linode) => { + cy.defer(() => createTestLinode({ booted: true })).then((linode) => { cy.intercept( 'DELETE', apiMatcher(`linode/instances/${linode.id}/disks/*`) @@ -127,7 +127,7 @@ describe('linode storage tab', () => { it('delete disk', () => { const diskName = 'cy-test-disk'; - cy.defer(createTestLinode({ image: null })).then((linode) => { + cy.defer(() => createTestLinode({ image: null })).then((linode) => { cy.intercept( 'DELETE', apiMatcher(`linode/instances/${linode.id}/disks/*`) @@ -157,7 +157,7 @@ describe('linode storage tab', () => { it('add a disk', () => { const diskName = 'cy-test-disk'; - cy.defer(createTestLinode({ image: null })).then((linode: Linode) => { + cy.defer(() => createTestLinode({ image: null })).then((linode: Linode) => { cy.intercept( 'POST', apiMatcher(`/linode/instances/${linode.id}/disks`) @@ -171,7 +171,7 @@ describe('linode storage tab', () => { it('resize disk', () => { const diskName = 'Debian 10 Disk'; - cy.defer(createTestLinode({ image: null })).then((linode: Linode) => { + cy.defer(() => createTestLinode({ image: null })).then((linode: Linode) => { cy.intercept( 'POST', apiMatcher(`linode/instances/${linode.id}/disks`) diff --git a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts index 0e6f00e2ffa..2c2c98ce89b 100644 --- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts @@ -118,45 +118,46 @@ describe('rebuild linode', () => { region: chooseRegion().id, }); - cy.defer(createTestLinode(linodeCreatePayload), 'creating Linode').then( - (linode: Linode) => { - interceptRebuildLinode(linode.id).as('linodeRebuild'); - - cy.visitWithLogin(`/linodes/${linode.id}`); - cy.findByText('RUNNING').should('be.visible'); - - openRebuildDialog(linode.label); - findRebuildDialog(linode.label).within(() => { - // "From Image" should be selected by default; no need to change the value. - ui.select.findByText('From Image').should('be.visible'); - - ui.select - .findByText('Choose an image') - .should('be.visible') - .click() - .type(`${image}{enter}`); - - // Type to confirm. - cy.findByLabelText('Linode Label').type(linode.label); - - // checkPasswordComplexity(rootPassword); - assertPasswordComplexity(weakPassword, 'Weak'); - submitRebuild(); - cy.findByText(passwordComplexityError).should('be.visible'); - - assertPasswordComplexity(fairPassword, 'Fair'); - submitRebuild(); - cy.findByText(passwordComplexityError).should('be.visible'); - - assertPasswordComplexity(rootPassword, 'Good'); - submitRebuild(); - cy.findByText(passwordComplexityError).should('not.exist'); - }); + cy.defer( + () => createTestLinode(linodeCreatePayload), + 'creating Linode' + ).then((linode: Linode) => { + interceptRebuildLinode(linode.id).as('linodeRebuild'); + + cy.visitWithLogin(`/linodes/${linode.id}`); + cy.findByText('RUNNING').should('be.visible'); + + openRebuildDialog(linode.label); + findRebuildDialog(linode.label).within(() => { + // "From Image" should be selected by default; no need to change the value. + ui.select.findByText('From Image').should('be.visible'); + + ui.select + .findByText('Choose an image') + .should('be.visible') + .click() + .type(`${image}{enter}`); + + // Type to confirm. + cy.findByLabelText('Linode Label').type(linode.label); + + // checkPasswordComplexity(rootPassword); + assertPasswordComplexity(weakPassword, 'Weak'); + submitRebuild(); + cy.findByText(passwordComplexityError).should('be.visible'); + + assertPasswordComplexity(fairPassword, 'Fair'); + submitRebuild(); + cy.findByText(passwordComplexityError).should('be.visible'); + + assertPasswordComplexity(rootPassword, 'Good'); + submitRebuild(); + cy.findByText(passwordComplexityError).should('not.exist'); + }); - cy.wait('@linodeRebuild'); - cy.contains('REBUILDING').should('be.visible'); - } - ); + cy.wait('@linodeRebuild'); + cy.contains('REBUILDING').should('be.visible'); + }); }); /* @@ -172,52 +173,53 @@ describe('rebuild linode', () => { region: chooseRegion().id, }); - cy.defer(createTestLinode(linodeCreatePayload), 'creating Linode').then( - (linode: Linode) => { - interceptRebuildLinode(linode.id).as('linodeRebuild'); - interceptGetStackScripts().as('getStackScripts'); - cy.visitWithLogin(`/linodes/${linode.id}`); - cy.findByText('RUNNING').should('be.visible'); - - openRebuildDialog(linode.label); - findRebuildDialog(linode.label).within(() => { - ui.select.findByText('From Image').click(); - - ui.select - .findItemByText('From Community StackScript') - .should('be.visible') - .click(); - - cy.wait('@getStackScripts'); - cy.findByLabelText('Search by Label, Username, or Description') - .should('be.visible') - .type(`${stackScriptName}`); - - cy.wait('@getStackScripts'); - cy.findByLabelText('List of StackScripts').within(() => { - cy.get(`[id="${stackScriptId}"][type="radio"]`).click(); - }); - - ui.select - .findByText('Choose an image') - .scrollIntoView() - .should('be.visible') - .click(); - - ui.select.findItemByText(image).should('be.visible').click(); - - cy.findByLabelText('Linode Label') - .should('be.visible') - .type(linode.label); - - assertPasswordComplexity(rootPassword, 'Good'); - submitRebuild(); + cy.defer( + () => createTestLinode(linodeCreatePayload), + 'creating Linode' + ).then((linode: Linode) => { + interceptRebuildLinode(linode.id).as('linodeRebuild'); + interceptGetStackScripts().as('getStackScripts'); + cy.visitWithLogin(`/linodes/${linode.id}`); + cy.findByText('RUNNING').should('be.visible'); + + openRebuildDialog(linode.label); + findRebuildDialog(linode.label).within(() => { + ui.select.findByText('From Image').click(); + + ui.select + .findItemByText('From Community StackScript') + .should('be.visible') + .click(); + + cy.wait('@getStackScripts'); + cy.findByLabelText('Search by Label, Username, or Description') + .should('be.visible') + .type(`${stackScriptName}`); + + cy.wait('@getStackScripts'); + cy.findByLabelText('List of StackScripts').within(() => { + cy.get(`[id="${stackScriptId}"][type="radio"]`).click(); }); - cy.wait('@linodeRebuild'); - cy.contains('REBUILDING').should('be.visible'); - } - ); + ui.select + .findByText('Choose an image') + .scrollIntoView() + .should('be.visible') + .click(); + + ui.select.findItemByText(image).should('be.visible').click(); + + cy.findByLabelText('Linode Label') + .should('be.visible') + .type(linode.label); + + assertPasswordComplexity(rootPassword, 'Good'); + submitRebuild(); + }); + + cy.wait('@linodeRebuild'); + cy.contains('REBUILDING').should('be.visible'); + }); }); /* @@ -251,7 +253,7 @@ describe('rebuild linode', () => { }; cy.defer( - createStackScriptAndLinode(stackScriptRequest, linodeRequest), + () => createStackScriptAndLinode(stackScriptRequest, linodeRequest), 'creating stackScript and linode' ).then(([stackScript, linode]) => { interceptRebuildLinode(linode.id).as('linodeRebuild'); diff --git a/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts index 658de76da84..ee9a741e274 100644 --- a/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts @@ -43,7 +43,7 @@ describe('Rescue Linodes', () => { region: chooseRegion().id, }); - cy.defer(createTestLinode(linodePayload), 'creating Linode').then( + cy.defer(() => createTestLinode(linodePayload), 'creating Linode').then( (linode: Linode) => { interceptGetLinodeDetails(linode.id).as('getLinode'); interceptRebootLinodeIntoRescueMode(linode.id).as( diff --git a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts index 69e0f3980b0..8dc827d6296 100644 --- a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts @@ -15,7 +15,7 @@ describe('resize linode', () => { it('resizes a linode by increasing size: warm migration', () => { mockGetFeatureFlagClientstream().as('getClientStream'); - cy.defer(createTestLinode({ booted: true })).then((linode) => { + cy.defer(() => createTestLinode({ booted: true })).then((linode) => { interceptLinodeResize(linode.id).as('linodeResize'); cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); cy.findByText('Shared CPU').click({ scrollBehavior: false }); @@ -35,7 +35,7 @@ describe('resize linode', () => { it('resizes a linode by increasing size: cold migration', () => { mockGetFeatureFlagClientstream().as('getClientStream'); - cy.defer(createTestLinode({ booted: true })).then((linode) => { + cy.defer(() => createTestLinode({ booted: true })).then((linode) => { interceptLinodeResize(linode.id).as('linodeResize'); cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); cy.findByText('Shared CPU').click({ scrollBehavior: false }); @@ -56,7 +56,7 @@ describe('resize linode', () => { it('resizes a linode by increasing size when offline: cold migration', () => { mockGetFeatureFlagClientstream().as('getClientStream'); - cy.defer(createTestLinode({ booted: true })).then((linode) => { + cy.defer(() => createTestLinode({ booted: true })).then((linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); // Turn off the linode to resize the disk @@ -98,86 +98,82 @@ describe('resize linode', () => { }); it.only('resizes a linode by decreasing size', () => { - cy.defer(createTestLinode({ booted: true, type: 'g6-standard-2' })).then( - (linode) => { - const diskName = 'Debian 11 Disk'; - const size = '50000'; // 50 GB - - // Error flow when attempting to resize a linode to a smaller size without - // resizing the disk to the requested size first. - interceptLinodeResize(linode.id).as('linodeResize'); - cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); - cy.findByText('Shared CPU').click({ scrollBehavior: false }); - containsVisible('Linode 2 GB'); - getClick('[id="g6-standard-1"]'); - cy.get('[data-testid="textfield-input"]').type(linode.label); - cy.get('[data-qa-resize="true"]').should('be.enabled').click(); - cy.wait('@linodeResize'); - // Failed to reduce the size of the linode - cy.contains( - 'The current disk size of your Linode is too large for the new service plan. Please resize your disk to accommodate the new plan. You can read our Resize Your Linode guide for more detailed instructions.' - ) - .scrollIntoView() - .should('be.visible'); - - // Normal flow when resizing a linode to a smaller size after first resizing - // its disk. - cy.visitWithLogin(`/linodes/${linode.id}`); - - // Turn off the linode to resize the disk - ui.button.findByTitle('Power Off').should('be.visible').click(); - - ui.dialog - .findByTitle(`Power Off Linode ${linode.label}?`) - .should('be.visible') - .then(() => { - ui.button - .findByTitle(`Power Off Linode`) - .should('be.visible') - .click(); - }); - - containsVisible('OFFLINE'); - - cy.visitWithLogin(`linodes/${linode.id}/storage`); - fbtVisible(diskName); - - cy.get(`[data-qa-disk="${diskName}"]`).within(() => { - cy.contains('Resize').should('be.enabled').click(); + cy.defer(() => + createTestLinode({ booted: true, type: 'g6-standard-2' }) + ).then((linode) => { + const diskName = 'Debian 11 Disk'; + const size = '50000'; // 50 GB + + // Error flow when attempting to resize a linode to a smaller size without + // resizing the disk to the requested size first. + interceptLinodeResize(linode.id).as('linodeResize'); + cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); + cy.findByText('Shared CPU').click({ scrollBehavior: false }); + containsVisible('Linode 2 GB'); + getClick('[id="g6-standard-1"]'); + cy.get('[data-testid="textfield-input"]').type(linode.label); + cy.get('[data-qa-resize="true"]').should('be.enabled').click(); + cy.wait('@linodeResize'); + // Failed to reduce the size of the linode + cy.contains( + 'The current disk size of your Linode is too large for the new service plan. Please resize your disk to accommodate the new plan. You can read our Resize Your Linode guide for more detailed instructions.' + ) + .scrollIntoView() + .should('be.visible'); + + // Normal flow when resizing a linode to a smaller size after first resizing + // its disk. + cy.visitWithLogin(`/linodes/${linode.id}`); + + // Turn off the linode to resize the disk + ui.button.findByTitle('Power Off').should('be.visible').click(); + + ui.dialog + .findByTitle(`Power Off Linode ${linode.label}?`) + .should('be.visible') + .then(() => { + ui.button + .findByTitle(`Power Off Linode`) + .should('be.visible') + .click(); }); - ui.drawer - .findByTitle(`Resize ${diskName}`) - .should('be.visible') - .within(() => { - cy.get('[id="size"]') - .should('be.visible') - .click() - .clear() - .type(size); - - ui.buttonGroup - .findButtonByTitle('Resize') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Wait until the disk resize is done. - ui.toast.assertMessage(`Disk ${diskName} successfully resized.`); - - interceptLinodeResize(linode.id).as('linodeResize'); - cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); - cy.findByText('Shared CPU').click({ scrollBehavior: false }); - containsVisible('Linode 2 GB'); - getClick('[id="g6-standard-1"]'); - cy.get('[data-testid="textfield-input"]').type(linode.label); - cy.get('[data-qa-resize="true"]').should('be.enabled').click(); - cy.wait('@linodeResize'); - cy.contains( - 'Your Linode will soon be automatically powered off, migrated, and restored to its previous state (booted or powered off).' - ).should('be.visible'); - } - ); + containsVisible('OFFLINE'); + + cy.visitWithLogin(`linodes/${linode.id}/storage`); + fbtVisible(diskName); + + cy.get(`[data-qa-disk="${diskName}"]`).within(() => { + cy.contains('Resize').should('be.enabled').click(); + }); + + ui.drawer + .findByTitle(`Resize ${diskName}`) + .should('be.visible') + .within(() => { + cy.get('[id="size"]').should('be.visible').click().clear().type(size); + + ui.buttonGroup + .findButtonByTitle('Resize') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Wait until the disk resize is done. + ui.toast.assertMessage(`Disk ${diskName} successfully resized.`); + + interceptLinodeResize(linode.id).as('linodeResize'); + cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); + cy.findByText('Shared CPU').click({ scrollBehavior: false }); + containsVisible('Linode 2 GB'); + getClick('[id="g6-standard-1"]'); + cy.get('[data-testid="textfield-input"]').type(linode.label); + cy.get('[data-qa-resize="true"]').should('be.enabled').click(); + cy.wait('@linodeResize'); + cy.contains( + 'Your Linode will soon be automatically powered off, migrated, and restored to its previous state (booted or powered off).' + ).should('be.visible'); + }); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts index cac96713e76..d2841b747b6 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts @@ -73,7 +73,7 @@ describe('delete linode', () => { const linodeCreatePayload = createLinodeRequestFactory.build({ label: randomLabel(), }); - cy.defer(createTestLinode(linodeCreatePayload)).then((linode) => { + cy.defer(() => createTestLinode(linodeCreatePayload)).then((linode) => { // catch delete request interceptDeleteLinode(linode.id).as('deleteLinode'); cy.visitWithLogin(`/linodes/${linode.id}`); @@ -120,7 +120,7 @@ describe('delete linode', () => { const linodeCreatePayload = createLinodeRequestFactory.build({ label: randomLabel(), }); - cy.defer(createTestLinode(linodeCreatePayload)).then((linode) => { + cy.defer(() => createTestLinode(linodeCreatePayload)).then((linode) => { // catch delete request interceptDeleteLinode(linode.id).as('deleteLinode'); cy.visitWithLogin(`/linodes/${linode.id}`); @@ -171,7 +171,7 @@ describe('delete linode', () => { const linodeCreatePayload = createLinodeRequestFactory.build({ label: randomLabel(), }); - cy.defer(createTestLinode(linodeCreatePayload)).then((linode) => { + cy.defer(() => createTestLinode(linodeCreatePayload)).then((linode) => { // catch delete request interceptDeleteLinode(linode.id).as('deleteLinode'); cy.visitWithLogin(`/linodes`); @@ -230,7 +230,7 @@ describe('delete linode', () => { mockGetAccountSettings(mockAccountSettings).as('getAccountSettings'); - cy.defer(createTwoLinodes()).then(([linodeA, linodeB]) => { + cy.defer(() => createTwoLinodes()).then(([linodeA, linodeB]) => { interceptDeleteLinode(linodeA.id).as('deleteLinode'); interceptDeleteLinode(linodeB.id).as('deleteLinode'); cy.visitWithLogin('/linodes', { preferenceOverrides }); diff --git a/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts b/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts index ec8cbeab797..7375e3e1e27 100644 --- a/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts @@ -21,7 +21,7 @@ describe('switch linode state', () => { // This works around an issue where the Linode API responds with a 400 // when attempting to reboot shortly after booting up when the Linode is // attached to a Cloud Firewall. - cy.defer( + cy.defer(() => createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) ).then((linode: Linode) => { cy.visitWithLogin('/linodes'); @@ -68,7 +68,7 @@ describe('switch linode state', () => { // This works around an issue where the Linode API responds with a 400 // when attempting to reboot shortly after booting up when the Linode is // attached to a Cloud Firewall. - cy.defer( + cy.defer(() => createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) ).then((linode: Linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); @@ -98,39 +98,41 @@ describe('switch linode state', () => { * - Waits for Linode to finish booting up before succeeding. */ it('powers on a linode from landing page', () => { - cy.defer(createTestLinode({ booted: false })).then((linode: Linode) => { - cy.visitWithLogin('/linodes'); - cy.get(`[data-qa-linode="${linode.label}"]`) - .should('be.visible') - .within(() => { - cy.contains('Offline').should('be.visible'); - }); - - ui.actionMenu - .findByTitle(`Action menu for Linode ${linode.label}`) - .should('be.visible') - .click(); - - ui.actionMenuItem.findByTitle('Power On').should('be.visible').click(); - - ui.dialog - .findByTitle(`Power On Linode ${linode.label}?`) - .should('be.visible') - .within(() => { - ui.button - .findByTitle('Power On Linode') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.get(`[data-qa-linode="${linode.label}"]`) - .should('be.visible') - .within(() => { - cy.contains('Booting').should('be.visible'); - cy.contains('Running', { timeout: 300000 }).should('be.visible'); - }); - }); + cy.defer(() => createTestLinode({ booted: false })).then( + (linode: Linode) => { + cy.visitWithLogin('/linodes'); + cy.get(`[data-qa-linode="${linode.label}"]`) + .should('be.visible') + .within(() => { + cy.contains('Offline').should('be.visible'); + }); + + ui.actionMenu + .findByTitle(`Action menu for Linode ${linode.label}`) + .should('be.visible') + .click(); + + ui.actionMenuItem.findByTitle('Power On').should('be.visible').click(); + + ui.dialog + .findByTitle(`Power On Linode ${linode.label}?`) + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Power On Linode') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.get(`[data-qa-linode="${linode.label}"]`) + .should('be.visible') + .within(() => { + cy.contains('Booting').should('be.visible'); + cy.contains('Running', { timeout: 300000 }).should('be.visible'); + }); + } + ); }); /* @@ -140,25 +142,27 @@ describe('switch linode state', () => { * - Does not wait for Linode to finish booting up before succeeding. */ it('powers on a linode from details page', () => { - cy.defer(createTestLinode({ booted: false })).then((linode: Linode) => { - cy.visitWithLogin(`/linodes/${linode.id}`); - cy.contains('OFFLINE').should('be.visible'); - cy.findByText(linode.label).should('be.visible'); - - cy.findByText('Power On').should('be.visible').click(); - ui.dialog - .findByTitle(`Power On Linode ${linode.label}?`) - .should('be.visible') - .within(() => { - ui.button - .findByTitle('Power On Linode') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.contains('BOOTING').should('be.visible'); - }); + cy.defer(() => createTestLinode({ booted: false })).then( + (linode: Linode) => { + cy.visitWithLogin(`/linodes/${linode.id}`); + cy.contains('OFFLINE').should('be.visible'); + cy.findByText(linode.label).should('be.visible'); + + cy.findByText('Power On').should('be.visible').click(); + ui.dialog + .findByTitle(`Power On Linode ${linode.label}?`) + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Power On Linode') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.contains('BOOTING').should('be.visible'); + } + ); }); /* @@ -172,7 +176,7 @@ describe('switch linode state', () => { // This works around an issue where the Linode API responds with a 400 // when attempting to reboot shortly after booting up when the Linode is // attached to a Cloud Firewall. - cy.defer( + cy.defer(() => createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) ).then((linode: Linode) => { cy.visitWithLogin('/linodes'); @@ -219,7 +223,7 @@ describe('switch linode state', () => { // This works around an issue where the Linode API responds with a 400 // when attempting to reboot shortly after booting up when the Linode is // attached to a Cloud Firewall. - cy.defer( + cy.defer(() => createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) ).then((linode: Linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); diff --git a/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts b/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts index 31e0ed477b6..4e87aa948a2 100644 --- a/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts @@ -11,7 +11,7 @@ describe('update linode label', () => { }); it('updates a linode label from details page', () => { - cy.defer(createTestLinode({ booted: true })).then((linode) => { + cy.defer(() => createTestLinode({ booted: true })).then((linode) => { const newLinodeLabel = randomLabel(); cy.visitWithLogin(`/linodes/${linode.id}`); cy.contains('RUNNING').should('be.visible'); @@ -28,7 +28,7 @@ describe('update linode label', () => { }); it('updates a linode label from the "Settings" tab', () => { - cy.defer(createTestLinode({ booted: true })).then((linode) => { + cy.defer(() => createTestLinode({ booted: true })).then((linode) => { const newLinodeLabel = randomLabel(); cy.visitWithLogin(`/linodes/${linode.id}`); cy.contains('RUNNING').should('be.visible'); diff --git a/packages/manager/cypress/e2e/core/longview/longview.spec.ts b/packages/manager/cypress/e2e/core/longview/longview.spec.ts index ebccb894f55..4ecfed6ca39 100644 --- a/packages/manager/cypress/e2e/core/longview/longview.spec.ts +++ b/packages/manager/cypress/e2e/core/longview/longview.spec.ts @@ -133,7 +133,7 @@ describe('longview', () => { }; // Create Linode and Longview Client before loading Longview landing page. - cy.defer(createLinodeAndClient(), { + cy.defer(createLinodeAndClient, { label: 'Creating Linode and Longview Client...', timeout: linodeCreateTimeout, }).then(([linode, client]: [Linode, LongviewClient]) => { diff --git a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts index 3cd493d1c1b..57db284984c 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts @@ -89,7 +89,7 @@ describe('create NodeBalancer', () => { private_ip: true, }; - cy.defer(createTestLinode(linodePayload)).then((linode) => { + cy.defer(() => createTestLinode(linodePayload)).then((linode) => { const nodeBal = nodeBalancerFactory.build({ label: randomLabel(), region: region.id, @@ -116,7 +116,7 @@ describe('create NodeBalancer', () => { // NodeBalancers require Linodes with private IPs. private_ip: true, }; - cy.defer(createTestLinode(linodePayload)).then((linode) => { + cy.defer(() => createTestLinode(linodePayload)).then((linode) => { const nodeBal = nodeBalancerFactory.build({ label: `${randomLabel()}-^`, ipv4: linode.ipv4[1], @@ -165,7 +165,7 @@ describe('create NodeBalancer', () => { // NodeBalancers require Linodes with private IPs. private_ip: true, }; - cy.defer(createTestLinode(linodePayload)).then((linode) => { + cy.defer(() => createTestLinode(linodePayload)).then((linode) => { const nodeBal = nodeBalancerFactory.build({ label: randomLabel(), region: initialRegion.id, diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts index 3594b8d7eab..9d8ef3a7757 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts @@ -126,7 +126,7 @@ describe('object storage access key end-to-end tests', () => { // Create a bucket before creating access key. cy.defer( - createBucket(bucketRequest), + () => createBucket(bucketRequest), 'creating Object Storage bucket' ).then(() => { const keyLabel = randomLabel(); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index cff27fd1f4f..87cdaf3371d 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts @@ -216,7 +216,7 @@ describe('object storage end-to-end tests', () => { ]; cy.defer( - setUpBucket(bucketLabel, bucketCluster), + () => setUpBucket(bucketLabel, bucketCluster), 'creating Object Storage bucket' ).then(() => { interceptUploadBucketObjectS3( @@ -409,7 +409,7 @@ describe('object storage end-to-end tests', () => { const bucketAccessPage = `/object-storage/buckets/${bucketCluster}/${bucketLabel}/access`; cy.defer( - setUpBucket(bucketLabel, bucketCluster), + () => setUpBucket(bucketLabel, bucketCluster), 'creating Object Storage bucket' ).then(() => { interceptGetBucketAccess(bucketLabel, bucketCluster).as( diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index 23587b19b95..9afa75c6ce6 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -309,7 +309,7 @@ describe('Create stackscripts', () => { interceptGetStackScripts().as('getStackScripts'); interceptCreateLinode().as('createLinode'); - cy.defer(createLinodeAndImage(), { + cy.defer(createLinodeAndImage, { label: 'creating Linode and Image', timeout: 360000, }).then((privateImage) => { diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts index 7d827e662ad..99f47b49309 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts @@ -103,7 +103,7 @@ describe('Community Stackscripts integration tests', () => { cy.get('[data-qa-stackscript-empty-msg="true"]').should('not.exist'); cy.findByText('Automate deployment scripts').should('not.exist'); - cy.defer(getProfile(), 'getting profile').then((profile: Profile) => { + cy.defer(getProfile, 'getting profile').then((profile: Profile) => { const dateFormatOptionsLanding = { timezone: profile.timezone, displayTime: false, diff --git a/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts index 647a53a42cd..6e2835aa20b 100644 --- a/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts @@ -74,7 +74,7 @@ describe('volume attach and detach flows', () => { createTestLinode(linodeRequest), ]); - cy.defer(entityPromise, 'creating Volume and Linode').then( + cy.defer(() => entityPromise, 'creating Volume and Linode').then( ([volume, linode]: [Volume, Linode]) => { interceptAttachVolume(volume.id).as('attachVolume'); interceptGetLinodeConfigs(linode.id).as('getLinodeConfigs'); diff --git a/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts index 80918644c3c..f589c9b979b 100644 --- a/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts @@ -48,7 +48,7 @@ describe('volume clone flow', () => { const cloneVolumeLabel = randomLabel(); - cy.defer(createActiveVolume(volumeRequest), 'creating volume').then( + cy.defer(() => createActiveVolume(volumeRequest), 'creating volume').then( (volume: Volume) => { interceptCloneVolume(volume.id).as('cloneVolume'); cy.visitWithLogin('/volumes', { diff --git a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts index 6131cd87cde..3041575a061 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts @@ -86,7 +86,7 @@ describe('volume create flow', () => { regionLabel: region.label, }; - cy.defer(createTestLinode(linodeRequest), 'creating Linode').then( + cy.defer(() => createTestLinode(linodeRequest), 'creating Linode').then( (linode) => { interceptCreateVolume().as('createVolume'); @@ -151,7 +151,7 @@ describe('volume create flow', () => { booted: false, }); - cy.defer(createTestLinode(linodeRequest), 'creating Linode').then( + cy.defer(() => createTestLinode(linodeRequest), 'creating Linode').then( (linode: Linode) => { const volume = { label: randomLabel(), diff --git a/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts index 919eb54e383..fe441dcf865 100644 --- a/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts @@ -34,7 +34,7 @@ describe('volume delete flow', () => { region: chooseRegion().id, }); - cy.defer(createVolume(volumeRequest), 'creating volume').then( + cy.defer(() => createVolume(volumeRequest), 'creating volume').then( (volume: Volume) => { interceptDeleteVolume(volume.id).as('deleteVolume'); cy.visitWithLogin('/volumes', { diff --git a/packages/manager/cypress/e2e/core/volumes/resize-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/resize-volume.spec.ts index c755e7d5d5e..f1ef8a5ddb7 100644 --- a/packages/manager/cypress/e2e/core/volumes/resize-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/resize-volume.spec.ts @@ -51,7 +51,7 @@ describe('volume resize flow', () => { size: oldSize, }); - cy.defer(createActiveVolume(volumeRequest), 'creating Volume').then( + cy.defer(() => createActiveVolume(volumeRequest), 'creating Volume').then( (volume: Volume) => { interceptResizeVolume(volume.id).as('resizeVolume'); cy.visitWithLogin('/volumes', { diff --git a/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts index c2cfe7283f3..2980b3e6a22 100644 --- a/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts @@ -24,7 +24,7 @@ describe('volume update flow', () => { const newLabel = randomLabel(); const newTags = [randomLabel(5), randomLabel(5), randomLabel(5)]; - cy.defer(createVolume(volumeRequest), 'creating volume').then( + cy.defer(() => createVolume(volumeRequest), 'creating volume').then( (volume: Volume) => { cy.visitWithLogin('/volumes', { // Temporarily force volume table to show up to 100 results per page. diff --git a/packages/manager/cypress/e2e/region/images/create-machine-image-from-linode.spec.ts b/packages/manager/cypress/e2e/region/images/create-machine-image-from-linode.spec.ts index dacc00ad54c..6ec7628ce88 100644 --- a/packages/manager/cypress/e2e/region/images/create-machine-image-from-linode.spec.ts +++ b/packages/manager/cypress/e2e/region/images/create-machine-image-from-linode.spec.ts @@ -32,7 +32,7 @@ describe('Capture Machine Images', () => { }); cy.defer( - createTestLinode(linodePayload, { waitForBoot: true }), + () => createTestLinode(linodePayload, { waitForBoot: true }), 'creating and booting Linode' ).then(([linode, disk]: [Linode, Disk]) => { cy.visitWithLogin('/images/create/disk'); diff --git a/packages/manager/cypress/e2e/region/images/update-delete-machine-image.spec.ts b/packages/manager/cypress/e2e/region/images/update-delete-machine-image.spec.ts index f786773eaa2..e2f6dc2e65f 100644 --- a/packages/manager/cypress/e2e/region/images/update-delete-machine-image.spec.ts +++ b/packages/manager/cypress/e2e/region/images/update-delete-machine-image.spec.ts @@ -73,7 +73,7 @@ describe('Delete Machine Images', () => { // Wait for machine image to become ready, then begin test. cy.fixture('machine-images/test-image.gz', null).then( (imageFileContents) => { - cy.defer(uploadMachineImage(region, imageFileContents), { + cy.defer(() => uploadMachineImage(region, imageFileContents), { label: 'uploading Machine Image', timeout: imageUploadProcessingTimeout, }).then((image: Image) => { diff --git a/packages/manager/cypress/e2e/region/linodes/delete-linode.spec.ts b/packages/manager/cypress/e2e/region/linodes/delete-linode.spec.ts index dec88cbe6cc..c0ccb540084 100644 --- a/packages/manager/cypress/e2e/region/linodes/delete-linode.spec.ts +++ b/packages/manager/cypress/e2e/region/linodes/delete-linode.spec.ts @@ -33,7 +33,7 @@ describeRegions('Delete Linodes', (region: Region) => { // Create a Linode before navigating to its details page to delete it. cy.defer( - createTestLinode(linodeCreatePayload), + () => createTestLinode(linodeCreatePayload), `creating Linode in ${region.label}` ).then((linode: Linode) => { interceptGetLinodeDetails(linode.id).as('getLinode'); diff --git a/packages/manager/cypress/e2e/region/linodes/update-linode.spec.ts b/packages/manager/cypress/e2e/region/linodes/update-linode.spec.ts index 6850c680e42..5bfdfb78130 100644 --- a/packages/manager/cypress/e2e/region/linodes/update-linode.spec.ts +++ b/packages/manager/cypress/e2e/region/linodes/update-linode.spec.ts @@ -35,7 +35,7 @@ describeRegions('Can update Linodes', (region) => { */ it('can update a Linode label', () => { cy.defer( - createTestLinode(makeLinodePayload(region.id, true)), + () => createTestLinode(makeLinodePayload(region.id, true)), 'creating Linode' ).then((linode: Linode) => { const newLabel = randomLabel(); @@ -104,7 +104,7 @@ describeRegions('Can update Linodes', (region) => { return [linode, disks[0]]; }; - cy.defer(createLinodeAndGetDisk(), 'creating Linode').then( + cy.defer(() => createLinodeAndGetDisk(), 'creating Linode').then( ([linode, disk]: [Linode, Disk]) => { // Navigate to Linode details page. interceptGetLinodeDetails(linode.id).as('getLinode'); diff --git a/packages/manager/cypress/support/e2e.ts b/packages/manager/cypress/support/e2e.ts index dc2dfc94cdf..36169150553 100644 --- a/packages/manager/cypress/support/e2e.ts +++ b/packages/manager/cypress/support/e2e.ts @@ -22,6 +22,8 @@ import 'cypress-real-events/support'; import './setup/defer-command'; import './setup/login-command'; import './setup/page-visit-tracking-commands'; +import './setup/test-tagging'; + chai.use(chaiString); chai.use(function (chai, utils) { diff --git a/packages/manager/cypress/support/index.d.ts b/packages/manager/cypress/support/index.d.ts index 1439322a740..b6c7a2e19e1 100644 --- a/packages/manager/cypress/support/index.d.ts +++ b/packages/manager/cypress/support/index.d.ts @@ -1,9 +1,14 @@ import { Labelable } from './commands'; import type { LinodeVisitOptions } from './login.ts'; +import type { TestTag } from 'support/util/tag'; declare global { namespace Cypress { + interface Cypress { + mocha: Mocha; + } + interface Chainable { /** * Custom command to select DOM element by data-cy attribute. @@ -17,7 +22,7 @@ declare global { * @example cy.defer(new Promise('value')).then((val) => {...}) */ defer( - promise: Promise, + promiseGenerator: () => Promise, labelOrOptions?: | Partial | string @@ -63,12 +68,33 @@ declare global { */ expectNewPageVisit(alias: string): Chainable<>; + /** + * Sets tags for the current runnable. + * + * Alias for `tag()` in `support/util/tag.ts`. + * + * @param tags - Tags to set for test or runnable. + */ + tag(...tags: TestTag[]): void; + + /** + * Adds tags for the given runnable. + * + * If tags have already been set (e.g. using a hook), this method will add + * the given tags in addition the tags that have already been set. + * + * Alias for `addTag()` in `support/util/tag.ts`. + * + * @param tags - Test tags. + */ + addTag(...tags: TestTag[]): void; + /** * Internal Cypress command to retrieve test state. * * @param state - Cypress internal state to retrieve. */ - state(state: string): any; + state(state?: string): any; } } } diff --git a/packages/manager/cypress/support/plugins/test-tagging-info.ts b/packages/manager/cypress/support/plugins/test-tagging-info.ts new file mode 100644 index 00000000000..47aa97cd462 --- /dev/null +++ b/packages/manager/cypress/support/plugins/test-tagging-info.ts @@ -0,0 +1,35 @@ +import { CypressPlugin } from './plugin'; +import { + validateQuery, + getHumanReadableQueryRules, + getQueryRules, +} from '../util/tag'; + +const envVarName = 'CY_TEST_TAGS'; +export const logTestTagInfo: CypressPlugin = (_on, config) => { + if (config.env[envVarName]) { + const query = config.env[envVarName]; + + if (!validateQuery(query)) { + throw `Failed to validate tag query '${query}'. Please double check the syntax of your query.`; + } + + const rules = getQueryRules(query); + + if (rules.length) { + console.info( + `Running tests that satisfy all of the following tag rules for query '${query}':` + ); + + console.table( + getHumanReadableQueryRules(query).reduce( + (acc: {}, cur: string, index: number) => { + acc[index] = cur; + return acc; + }, + {} + ) + ); + } + } +}; diff --git a/packages/manager/cypress/support/setup/defer-command.ts b/packages/manager/cypress/support/setup/defer-command.ts index f5f34068674..a667d505030 100644 --- a/packages/manager/cypress/support/setup/defer-command.ts +++ b/packages/manager/cypress/support/setup/defer-command.ts @@ -162,7 +162,7 @@ Cypress.Commands.add( 'defer', { prevSubject: false }, ( - promise: Promise, + promiseGenerator: () => Promise, labelOrOptions?: | Partial | string @@ -205,7 +205,7 @@ Cypress.Commands.add( const wrapPromise = async (): Promise => { let result: T; try { - result = await promise; + result = await promiseGenerator(); } catch (e: any) { commandLog.error(e); // If we're getting rate limited, timeout for 15 seconds so that diff --git a/packages/manager/cypress/support/setup/test-tagging.ts b/packages/manager/cypress/support/setup/test-tagging.ts new file mode 100644 index 00000000000..5dbd48c8405 --- /dev/null +++ b/packages/manager/cypress/support/setup/test-tagging.ts @@ -0,0 +1,41 @@ +/** + * @file Exposes the `tag` util from the `cy` object. + */ + +import { Runnable, Test } from 'mocha'; +import { tag, addTag } from 'support/util/tag'; +import { evaluateQuery } from 'support/util/tag'; + +// Expose tag utils from the `cy` object. +// Similar to `cy.state`, and unlike other functions exposed in `cy`, these do not +// queue Cypress commands. Instead, they modify the test tag map upon execution. +cy.tag = tag; +cy.addTag = addTag; + +const query = Cypress.env('CY_TEST_TAGS') ?? ''; + +/** + * + */ +Cypress.on('test:before:run', (_test: Test, _runnable: Runnable) => { + /* + * Looks for the first command that does not belong in a hook and evalutes tags. + * + * Waiting for the first command to begin executing ensures that test context + * is set up and that tags have been assigned to the test. + */ + const commandHandler = () => { + const context = cy.state('ctx'); + if (context && context.test?.type !== 'hook') { + const tags = context?.tags ?? []; + + if (!evaluateQuery(query, tags)) { + context.skip(); + } + + Cypress.removeListener('command:start', commandHandler); + } + }; + + Cypress.on('command:start', commandHandler); +}); diff --git a/packages/manager/cypress/support/util/arrays.ts b/packages/manager/cypress/support/util/arrays.ts index 74ce77cdf9f..b713f115d69 100644 --- a/packages/manager/cypress/support/util/arrays.ts +++ b/packages/manager/cypress/support/util/arrays.ts @@ -32,3 +32,14 @@ export const shuffleArray = (unsortedArray: T[]): T[] => { .sort((a, b) => a.sort - b.sort) .map(({ value }) => value); }; + +/** + * Returns a copy of an array with duplicate items removed. + * + * @param array - Array from which to create de-duplicated array. + * + * @returns Copy of `array` with duplicate items removed. + */ +export const removeDuplicates = (array: T[]): T[] => { + return Array.from(new Set(array)); +}; diff --git a/packages/manager/cypress/support/util/cleanup.ts b/packages/manager/cypress/support/util/cleanup.ts index b8260b6773d..0621107c4cf 100644 --- a/packages/manager/cypress/support/util/cleanup.ts +++ b/packages/manager/cypress/support/util/cleanup.ts @@ -65,7 +65,7 @@ const cleanUpMap: CleanUpMap = { */ export const cleanUp = (resources: CleanUpResource | CleanUpResource[]) => { const resourcesArray = Array.isArray(resources) ? resources : [resources]; - const promise = async () => { + const promiseGenerator = async () => { for (const resource of resourcesArray) { const cleanFunction = cleanUpMap[resource]; // Perform clean-up sequentially to avoid API rate limiting. @@ -74,7 +74,7 @@ export const cleanUp = (resources: CleanUpResource | CleanUpResource[]) => { } }; return cy.defer( - promise(), + promiseGenerator, `cleaning up test resources: ${resourcesArray.join(', ')}` ); }; diff --git a/packages/manager/cypress/support/util/tag.ts b/packages/manager/cypress/support/util/tag.ts new file mode 100644 index 00000000000..c7fa37f0a08 --- /dev/null +++ b/packages/manager/cypress/support/util/tag.ts @@ -0,0 +1,170 @@ +import type { Context } from 'mocha'; +import { removeDuplicates } from './arrays'; + +const queryRegex = /(?:-|\+)?([^\s]+)/g; + +/** + * Allowed test tags. + */ +export type TestTag = + // Feature-related tags. + // Used to identify tests which deal with a certain feature or features. + | 'feat:linodes' + | 'feat:placementGroups' + + // Purpose-related tags. + // Describes additional uses for which a test may serve. + // For example, a test which creates a Linode end-to-end could be useful for + // DC testing purposes even if that is not the primary purpose of the test. + | 'purpose:dcTesting' + | 'purpose:smokeTesting' + + // Method-related tags. + // Describe the way the tests operate -- either end-to-end using real API requests, + // or integration using mocked API requests. + | 'method:e2e' + | 'method:mock'; + +/** + * + */ +export const testTagMap: Map = new Map(); + +/** + * Extended Mocha context that contains a tags property. + * + * `Context` already allows for arbitrary key/value pairs, this type simply + * enforces the `tags` property as an optional array of strings. + */ +export type ExtendedMochaContext = Context & { + tags?: string[]; +}; + +/** + * Sets tags for the current runnable. + * + * @param tags - Test tags. + */ +export const tag = (...tags: TestTag[]) => { + const extendedMochaContext = cy.state('ctx') as ExtendedMochaContext; + + if (extendedMochaContext) { + extendedMochaContext.tags = removeDuplicates(tags); + } +}; + +/** + * Adds tags for the given runnable. + * + * If tags have already been set (e.g. using a hook), this method will add + * the given tags in addition the tags that have already been set. + * + * @param tags - Test tags. + */ +export const addTag = (...tags: TestTag[]) => { + const extendedMochaContext = cy.state('ctx') as ExtendedMochaContext; + + if (extendedMochaContext) { + extendedMochaContext.tags = removeDuplicates([ + ...(extendedMochaContext.tags || []), + ...tags, + ]); + } +}; + +/** + * Returns a boolean indicating whether `query` is a valid test tag query. + * + * @param query - Test tag query string. + * + * @return `true` if `query` is valid, `false` otherwise. + */ +export const validateQuery = (query: string) => { + // An empty string is a special case. + if (query === '') { + return true; + } + const result = queryRegex.test(query); + queryRegex.lastIndex = 0; + return result; +}; + +/** + * Gets an array of individual query rules from a query string. + * + * @param query - Query string from which to get query rules. + * + * @example + * // Query for all Linode or Volume tests, which also test Placement Groups, + * // and which are not end-to-end. + * const query = '+feat:linode,feat:volumes feat:placementGroups -e2e' + * getQueryRules(query); + * // Expected output: ['+feat:linode,feat:volumes', '+feat:placementGroups', '-e2e'] + * + * @returns Array of query rule strings. + */ +export const getQueryRules = (query: string) => { + return (query.match(queryRegex) ?? []).map((rule: string) => { + if (!['-', '+'].includes(rule[0]) || rule.length === 1) { + return `+${rule}`; + } + return rule; + }); +}; + +/** + * Returns an array of human-readable query rules. + * + * This can be useful for presentation or debugging purposes. + */ +export const getHumanReadableQueryRules = (query: string) => { + return getQueryRules(query).map((queryRule: string) => { + const queryOperation = queryRule[0]; + const queryOperands = queryRule.slice(1).split(','); + + const operationName = + queryOperation === '+' ? `HAS TAG` : `DOES NOT HAVE TAG`; + const tagNames = queryOperands.join(' OR '); + + return `${operationName} ${tagNames}`; + }); +}; + +/** + * Evaluates a query rule against an array of test tags. + * + * @param queryRule - Query rule against which to evaluate test tags. + * @param tags - Tags to evaluate. + * + * @returns `true` if tags satisfy the query rule, `false` otherwise. + */ +export const evaluateQueryRule = ( + queryRule: string, + tags: TestTag[] +): boolean => { + const queryOperation = queryRule[0]; // Either '-' or '+'. + const queryOperands = queryRule.slice(1).split(','); // The tags to check. + + return queryOperation === '+' + ? tags.some((tag) => queryOperands.includes(tag)) + : !tags.some((tag) => queryOperands.includes(tag)); +}; + +/** + * Evaluates a query against an array of test tags. + * + * Tags are considered to satisfy query if every query rule evaluates to `true`. + * + * @param query - Query against which to evaluate test tags. + * @param tags - Tags to evaluate. + * + * @returns `true` if tags satisfy query, `false` otherwise. + */ +export const evaluateQuery = (query: string, tags: TestTag[]): boolean => { + if (!validateQuery(query)) { + throw new Error(`Invalid test tag query '${query}'`); + } + return getQueryRules(query).every((queryRule) => + evaluateQueryRule(queryRule, tags) + ); +}; From 384316923d8a0e05820a0d24065c5ad1c5680fd4 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 12 Jun 2024 12:23:19 -0400 Subject: [PATCH 078/163] feat: [M3-8017] - Support Linodes in Distributed Compute Regions on Image Create (#10544) * add notice and helper text * add juicy unit testing * don't use the word `edge` * Added changeset: Informational notice about capturing an image from a Linode in a distributed compute region * increace overall spacing @hkhalil-akamai * remove helper text as requested by ux * remove unit test for helper text --------- Co-authored-by: Banks Nussman --- .../pr-10544-added-1717530161965.md | 5 + .../ImagesCreate/CreateImageTab.test.tsx | 168 ++++++++++++++++++ .../Images/ImagesCreate/CreateImageTab.tsx | 60 ++++--- 3 files changed, 207 insertions(+), 26 deletions(-) create mode 100644 packages/manager/.changeset/pr-10544-added-1717530161965.md create mode 100644 packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx diff --git a/packages/manager/.changeset/pr-10544-added-1717530161965.md b/packages/manager/.changeset/pr-10544-added-1717530161965.md new file mode 100644 index 00000000000..f1a78d8ef7e --- /dev/null +++ b/packages/manager/.changeset/pr-10544-added-1717530161965.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Informational notice about capturing an image from a Linode in a distributed compute region ([#10544](https://github.com/linode/manager/pull/10544)) diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx new file mode 100644 index 00000000000..cdda9cd1340 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.test.tsx @@ -0,0 +1,168 @@ +import { userEvent } from '@testing-library/user-event'; +import React from 'react'; + +import { + imageFactory, + linodeDiskFactory, + linodeFactory, + regionFactory, +} from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CreateImageTab } from './CreateImageTab'; + +describe('CreateImageTab', () => { + it('should render fields, titles, and buttons in their default state', () => { + const { getByLabelText, getByText } = renderWithTheme(); + + expect(getByText('Select Linode & Disk')).toBeVisible(); + + expect(getByLabelText('Linode')).toBeVisible(); + + const diskSelect = getByLabelText('Disk'); + + expect(diskSelect).toBeVisible(); + expect(diskSelect).toBeDisabled(); + + expect(getByText('Select a Linode to see available disks')).toBeVisible(); + + expect(getByText('Image Details')).toBeVisible(); + + expect(getByLabelText('Label')).toBeVisible(); + expect(getByLabelText('Add Tags')).toBeVisible(); + expect(getByLabelText('Description')).toBeVisible(); + + const submitButton = getByText('Create Image').closest('button'); + + expect(submitButton).toBeVisible(); + expect(submitButton).toBeEnabled(); + }); + + it('should render client side validation errors', async () => { + const { getByText } = renderWithTheme(); + + const submitButton = getByText('Create Image').closest('button'); + + await userEvent.click(submitButton!); + + expect(getByText('Disk is required.')).toBeVisible(); + }); + + it('should allow the user to select a disk and submit the form', async () => { + const linode = linodeFactory.build(); + const disk = linodeDiskFactory.build(); + const image = imageFactory.build(); + + server.use( + http.get('*/v4/linode/instances', () => { + return HttpResponse.json(makeResourcePage([linode])); + }), + http.get('*/v4/linode/instances/:id/disks', () => { + return HttpResponse.json(makeResourcePage([disk])); + }), + http.post('*/v4/images', () => { + return HttpResponse.json(image); + }) + ); + + const { + findByText, + getByLabelText, + getByText, + queryByText, + } = renderWithTheme(); + + const linodeSelect = getByLabelText('Linode'); + + await userEvent.click(linodeSelect); + + const linodeOption = await findByText(linode.label); + + await userEvent.click(linodeOption); + + const diskSelect = getByLabelText('Disk'); + + // Once a Linode is selected, the Disk select should become enabled + expect(diskSelect).toBeEnabled(); + expect(queryByText('Select a Linode to see available disks')).toBeNull(); + + await userEvent.click(diskSelect); + + const diskOption = await findByText(disk.label); + + await userEvent.click(diskOption); + + const submitButton = getByText('Create Image').closest('button'); + + await userEvent.click(submitButton!); + + // Verify success toast shows + await findByText('Image scheduled for creation.'); + }); + + it('should render a notice if the user selects a Linode in a distributed compute region', async () => { + const region = regionFactory.build({ site_type: 'distributed' }); + const linode = linodeFactory.build({ region: region.id }); + + server.use( + http.get('*/v4/linode/instances', () => { + return HttpResponse.json(makeResourcePage([linode])); + }), + http.get('*/v4/linode/instances/:id', () => { + return HttpResponse.json(linode); + }), + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + + const { findByText, getByLabelText } = renderWithTheme(); + + const linodeSelect = getByLabelText('Linode'); + + await userEvent.click(linodeSelect); + + const linodeOption = await findByText(linode.label); + + await userEvent.click(linodeOption); + + // Verify distributed compute region notice renders + await findByText( + 'This Linode is in a distributed compute region. Images captured from this Linode will be stored in the closest core site.' + ); + }); + + it('should render an encryption notice if disk encryption is enabled and the Linode is not in a distributed compute region', async () => { + const region = regionFactory.build({ site_type: 'core' }); + const linode = linodeFactory.build({ region: region.id }); + + server.use( + http.get('*/v4/linode/instances', () => { + return HttpResponse.json(makeResourcePage([linode])); + }), + http.get('*/v4/linode/instances/:id', () => { + return HttpResponse.json(linode); + }), + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + + const { findByText, getByLabelText } = renderWithTheme(, { + flags: { linodeDiskEncryption: true }, + }); + + const linodeSelect = getByLabelText('Linode'); + + await userEvent.click(linodeSelect); + + const linodeOption = await findByText(linode.label); + + await userEvent.click(linodeOption); + + // Verify encryption notice renders + await findByText('Virtual Machine Images are not encrypted.'); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx index d32eca891ae..b1f27f2e10b 100644 --- a/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/CreateImageTab.tsx @@ -101,16 +101,11 @@ export const CreateImageTab = () => { const isRawDisk = selectedDisk?.filesystem === 'raw'; - /* - We only want to display the notice about disk encryption if: - 1. the Disk Encryption feature is enabled - 2. the selected linode is not in an Edge region - */ const { data: regionsData } = useRegionsQuery(); const { data: linode } = useLinodeQuery( selectedLinodeId ?? -1, - Boolean(selectedLinodeId) && isDiskEncryptionFeatureEnabled + selectedLinodeId !== null ); const linodeIsInDistributedRegion = getIsDistributedRegion( @@ -118,6 +113,23 @@ export const CreateImageTab = () => { linode?.region ?? '' ); + /* + We only want to display the notice about disk encryption if: + 1. the Disk Encryption feature is enabled + 2. a linode is selected + 2. the selected linode is not in an Edge region + */ + const showDiskEncryptionWarning = + isDiskEncryptionFeatureEnabled && + selectedLinodeId !== null && + !linodeIsInDistributedRegion; + + const linodeSelectHelperText = grants?.linode.some( + (grant) => grant.permissions === 'read_only' + ) + ? 'You can only create Images from Linodes you have read/write access to.' + : undefined; + return ( @@ -135,7 +147,7 @@ export const CreateImageTab = () => { variant="error" /> )} - + Select Linode & Disk By default, Linode images are limited to 6144 MB of data per disk. @@ -153,6 +165,12 @@ export const CreateImageTab = () => { created from a raw disk or a disk that’s formatted using a custom file system. + {linodeIsInDistributedRegion && ( + + This Linode is in a distributed compute region. Images captured + from this Linode will be stored in the closest core site. + + )} { ) : undefined } - helperText={ - grants?.linode.some( - (grant) => grant.permissions === 'read_only' - ) - ? 'You can only create Images from Linodes you have read/write access to.' - : undefined - } onSelectionChange={(linode) => { setSelectedLinodeId(linode?.id ?? null); if (linode === null) { @@ -178,21 +189,18 @@ export const CreateImageTab = () => { } }} disabled={isImageCreateRestricted} + helperText={linodeSelectHelperText} noMarginTop required value={selectedLinodeId} /> - {isDiskEncryptionFeatureEnabled && - !linodeIsInDistributedRegion && - selectedLinodeId !== null && ( - - ({ fontFamily: theme.font.normal })} - > - {DISK_ENCRYPTION_IMAGES_CAVEAT_COPY} - - - )} + {showDiskEncryptionWarning && ( + + ({ fontFamily: theme.font.normal })}> + {DISK_ENCRYPTION_IMAGES_CAVEAT_COPY} + + + )} ( { - + Image Details ( From f3e361bd32ec3f2aeb3d1eb7f6fc7ce1944e0a2c Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 12 Jun 2024 14:52:21 -0400 Subject: [PATCH 079/163] refactor: [M3-8216] - NodeBalancer Query Key Factory (#10556) * save progress * finish all refactoring * changeset * feedback @mjac0bs --------- Co-authored-by: Banks Nussman --- .../pr-10556-tech-stories-1717782967942.md | 5 + .../Devices/AddNodebalancerDrawer.tsx | 14 +- .../Devices/RemoveDeviceDialog.tsx | 37 +- .../Rules/FirewallRulesLanding.tsx | 31 +- .../FirewallLanding/CreateFirewallDrawer.tsx | 16 +- .../FirewallLanding/FirewallDialog.tsx | 22 +- .../NodeBalancerConfigurations.tsx | 32 +- .../NodeBalancerSummary/TablesPanel.tsx | 22 +- .../manager/src/hooks/useEventHandlers.ts | 4 +- packages/manager/src/queries/nodebalancers.ts | 383 ++++++++++-------- 10 files changed, 328 insertions(+), 238 deletions(-) create mode 100644 packages/manager/.changeset/pr-10556-tech-stories-1717782967942.md diff --git a/packages/manager/.changeset/pr-10556-tech-stories-1717782967942.md b/packages/manager/.changeset/pr-10556-tech-stories-1717782967942.md new file mode 100644 index 00000000000..6d5356bdca8 --- /dev/null +++ b/packages/manager/.changeset/pr-10556-tech-stories-1717782967942.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +NodeBalancer Query Key Factory ([#10556](https://github.com/linode/manager/pull/10556)) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx index b2698e00c3a..185aedaa1cc 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx @@ -1,4 +1,4 @@ -import { NodeBalancer } from '@linode/api-v4'; +import type { NodeBalancer } from '@linode/api-v4'; import { useTheme } from '@mui/material'; import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; @@ -16,7 +16,7 @@ import { useAddFirewallDeviceMutation, useAllFirewallsQuery, } from 'src/queries/firewalls'; -import { queryKey } from 'src/queries/nodebalancers'; +import { nodebalancerQueries } from 'src/queries/nodebalancers'; import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getEntityIdsByPermission } from 'src/utilities/grants'; @@ -73,12 +73,10 @@ export const AddNodebalancerDrawer = (props: Props) => { enqueueSnackbar(`NodeBalancer ${label} successfully added`, { variant: 'success', }); - queryClient.invalidateQueries([ - queryKey, - 'nodebalancer', - id, - 'firewalls', - ]); + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(id)._ctx.firewalls + .queryKey, + }); return; } failedNodebalancers.push(selectedNodebalancers[index]); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx index 0d9c3cd2d7a..ded61c873c9 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx @@ -1,4 +1,3 @@ -import { FirewallDevice } from '@linode/api-v4'; import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -9,7 +8,9 @@ import { Typography } from 'src/components/Typography'; import { useRemoveFirewallDeviceMutation } from 'src/queries/firewalls'; import { queryKey as firewallQueryKey } from 'src/queries/firewalls'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; -import { queryKey as nodebalancersQueryKey } from 'src/queries/nodebalancers'; +import { nodebalancerQueries } from 'src/queries/nodebalancers'; + +import type { FirewallDevice } from '@linode/api-v4'; export interface Props { device: FirewallDevice | undefined; @@ -36,10 +37,16 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { const deviceDialog = deviceType === 'linode' ? 'Linode' : 'NodeBalancer'; const onDelete = async () => { + if (!device) { + return; + } + await mutateAsync(); + const toastMessage = onService ? `Firewall ${firewallLabel} successfully unassigned` - : `${deviceDialog} ${device?.entity.label} successfully removed`; + : `${deviceDialog} ${device.entity.label} successfully removed`; + enqueueSnackbar(toastMessage, { variant: 'success', }); @@ -48,16 +55,22 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { enqueueSnackbar(error[0].reason, { variant: 'error' }); } - const queryKey = - deviceType === 'linode' ? linodesQueryKey : nodebalancersQueryKey; - // Since the linode was removed as a device, invalidate the linode-specific firewall query - queryClient.invalidateQueries([ - queryKey, - deviceType, - device?.entity.id, - 'firewalls', - ]); + if (deviceType === 'linode') { + queryClient.invalidateQueries([ + linodesQueryKey, + deviceType, + device.entity.id, + 'firewalls', + ]); + } + + if (deviceType === 'nodebalancer') { + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(device.entity.id)._ctx + .firewalls.queryKey, + }); + } queryClient.invalidateQueries([firewallQueryKey]); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx index 5d978b108fc..d2f2c482e75 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx @@ -13,7 +13,7 @@ import { useUpdateFirewallRulesMutation, } from 'src/queries/firewalls'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; -import { queryKey as nodebalancersQueryKey } from 'src/queries/nodebalancers'; +import { nodebalancerQueries } from 'src/queries/nodebalancers'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { FirewallRuleDrawer } from './FirewallRuleDrawer'; @@ -202,17 +202,24 @@ export const FirewallRulesLanding = React.memo((props: Props) => { .then((_rules) => { setSubmitting(false); // Invalidate Firewalls assigned to NodeBalancers and Linodes. - // eslint-disable-next-line no-unused-expressions - devices?.forEach((device) => - queryClient.invalidateQueries([ - device.entity.type === 'linode' - ? linodesQueryKey - : nodebalancersQueryKey, - device.entity.type, - device.entity.id, - 'firewalls', - ]) - ); + if (devices) { + for (const device of devices) { + if (device.entity.type === 'linode') { + queryClient.invalidateQueries([ + linodesQueryKey, + device.entity.type, + device.entity.id, + 'firewalls', + ]); + } + if (device.entity.type === 'nodebalancer') { + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(device.entity.id) + ._ctx.firewalls.queryKey, + }); + } + } + } // Reset editor state. inboundDispatch({ rules: _rules.inbound ?? [], type: 'RESET' }); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx index 08be0d94aae..4dd3750ebb1 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx @@ -33,7 +33,6 @@ import { useCreateFirewall, } from 'src/queries/firewalls'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; -import { queryKey as nodebalancersQueryKey } from 'src/queries/nodebalancers'; import { useGrants } from 'src/queries/profile/profile'; import { sendLinodeCreateFormStepEvent } from 'src/utilities/analytics/formEventAnalytics'; import { getErrorMap } from 'src/utilities/errorUtils'; @@ -50,6 +49,7 @@ import { } from './constants'; import type { LinodeCreateType } from 'src/features/Linodes/LinodesCreate/types'; +import { nodebalancerQueries } from 'src/queries/nodebalancers'; export const READ_ONLY_DEVICES_HIDDEN_MESSAGE = 'Only services you have permission to modify are shown.'; @@ -151,14 +151,12 @@ export const CreateFirewallDrawer = React.memo( // Invalidate for NodeBalancers if (payload.devices?.nodebalancers) { - payload.devices.nodebalancers.forEach((nodebalancerId) => { - queryClient.invalidateQueries([ - nodebalancersQueryKey, - 'nodebalancer', - nodebalancerId, - 'firewalls', - ]); - }); + for (const id of payload.devices.nodebalancers) { + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(id)._ctx.firewalls + .queryKey, + }); + } } if (onFirewallCreated) { diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx index 0fc5940e071..70d06c9a4d3 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx @@ -8,7 +8,7 @@ import { useDeleteFirewall, useMutateFirewall } from 'src/queries/firewalls'; import { queryKey as firewallQueryKey } from 'src/queries/firewalls'; import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; -import { queryKey as nodebalancersQueryKey } from 'src/queries/nodebalancers'; +import { nodebalancerQueries } from 'src/queries/nodebalancers'; import { capitalize } from 'src/utilities/capitalize'; export type Mode = 'delete' | 'disable' | 'enable'; @@ -70,12 +70,20 @@ export const FirewallDialog = React.memo((props: Props) => { // eslint-disable-next-line no-unused-expressions devices?.forEach((device) => { const deviceType = device.entity.type; - queryClient.invalidateQueries([ - deviceType === 'linode' ? linodesQueryKey : nodebalancersQueryKey, - deviceType, - device.entity.id, - 'firewalls', - ]); + if (deviceType === 'linode') { + queryClient.invalidateQueries([ + linodesQueryKey, + deviceType, + device.entity.id, + 'firewalls', + ]); + } + if (deviceType === 'nodebalancer') { + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(device.entity.id)._ctx + .firewalls.queryKey, + }); + } }); if (mode === 'delete') { queryClient.invalidateQueries([firewallQueryKey]); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx index 0501f634aaa..620cd6e10f9 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx @@ -42,7 +42,6 @@ import { WithQueryClientProps, withQueryClient, } from 'src/containers/withQueryClient.container'; -import { queryKey } from 'src/queries/nodebalancers'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; @@ -63,6 +62,7 @@ import type { NodeBalancerConfigNodeFields, } from '../types'; import type { Grants } from '@linode/api-v4'; +import { nodebalancerQueries } from 'src/queries/nodebalancers'; const StyledPortsSpan = styled('span', { label: 'StyledPortsSpan', @@ -411,12 +411,10 @@ class NodeBalancerConfigurations extends React.Component< // actually delete a real config deleteNodeBalancerConfig(Number(nodeBalancerId), config.id) .then((_) => { - this.props.queryClient.invalidateQueries([ - queryKey, - 'nodebalancer', - Number(nodeBalancerId), - 'configs', - ]); + this.props.queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(Number(nodeBalancerId)) + ._ctx.configurations.queryKey, + }); // update config data const newConfigs = clone(this.state.configs); newConfigs.splice(idxToDelete, 1); @@ -827,12 +825,10 @@ class NodeBalancerConfigurations extends React.Component< createNodeBalancerConfig(Number(nodeBalancerId), configPayload) .then((nodeBalancerConfig) => { - this.props.queryClient.invalidateQueries([ - queryKey, - 'nodebalancer', - Number(nodeBalancerId), - 'configs', - ]); + this.props.queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(Number(nodeBalancerId)) + ._ctx.configurations.queryKey, + }); // update config data const newConfigs = clone(this.state.configs); newConfigs[idx] = { ...nodeBalancerConfig, nodes: [] }; @@ -941,12 +937,10 @@ class NodeBalancerConfigurations extends React.Component< configPayload ) .then((nodeBalancerConfig) => { - this.props.queryClient.invalidateQueries([ - queryKey, - 'nodebalancer', - Number(nodeBalancerId), - 'configs', - ]); + this.props.queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(Number(nodeBalancerId)) + ._ctx.configurations.queryKey, + }); // update config data const newConfigs = clone(this.state.configs); newConfigs[idx] = { ...nodeBalancerConfig, nodes: [] }; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx index 8b4303b3dc5..f981ac37906 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.tsx @@ -1,14 +1,10 @@ -import { Theme, useTheme } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import { useParams } from 'react-router-dom'; import PendingIcon from 'src/assets/icons/pending.svg'; import { AreaChart } from 'src/components/AreaChart/AreaChart'; -import { - NodeBalancerConnectionsTimeData, - Point, -} from 'src/components/AreaChart/types'; import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; @@ -16,15 +12,22 @@ import { Paper } from 'src/components/Paper'; import { Typography } from 'src/components/Typography'; import { formatBitsPerSecond } from 'src/features/Longview/shared/utilities'; import { - NODEBALANCER_STATS_NOT_READY_API_MESSAGE, useNodeBalancerQuery, - useNodeBalancerStats, + useNodeBalancerStatsQuery, } from 'src/queries/nodebalancers'; import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getUserTimezone } from 'src/utilities/getUserTimezone'; import { formatNumber, getMetrics } from 'src/utilities/statMetrics'; +import type { Theme } from '@mui/material/styles'; +import type { + NodeBalancerConnectionsTimeData, + Point, +} from 'src/components/AreaChart/types'; + +const NODEBALANCER_STATS_NOT_READY_API_MESSAGE = + 'Stats are unavailable at this time.'; const STATS_NOT_READY_TITLE = 'Stats for this NodeBalancer are not available yet'; @@ -36,9 +39,8 @@ export const TablesPanel = () => { const id = Number(nodeBalancerId); const { data: nodebalancer } = useNodeBalancerQuery(id); - const { data: stats, error, isLoading } = useNodeBalancerStats( - nodebalancer?.id ?? -1, - nodebalancer?.created + const { data: stats, error, isLoading } = useNodeBalancerStatsQuery( + nodebalancer?.id ?? -1 ); const statsErrorString = error diff --git a/packages/manager/src/hooks/useEventHandlers.ts b/packages/manager/src/hooks/useEventHandlers.ts index 56c8b6bad9e..d4692ae1f42 100644 --- a/packages/manager/src/hooks/useEventHandlers.ts +++ b/packages/manager/src/hooks/useEventHandlers.ts @@ -7,7 +7,7 @@ import { firewallEventsHandler } from 'src/queries/firewalls'; import { imageEventsHandler } from 'src/queries/images'; import { diskEventHandler } from 'src/queries/linodes/events'; import { linodeEventsHandler } from 'src/queries/linodes/events'; -import { nodebalanacerEventHandler } from 'src/queries/nodebalancers'; +import { nodebalancerEventHandler } from 'src/queries/nodebalancers'; import { sshKeyEventHandler } from 'src/queries/profile/profile'; import { stackScriptEventHandler } from 'src/queries/stackscripts'; import { supportTicketEventHandler } from 'src/queries/support'; @@ -58,7 +58,7 @@ export const eventHandlers: { }, { filter: (event) => event.action.startsWith('nodebalancer'), - handler: nodebalanacerEventHandler, + handler: nodebalancerEventHandler, }, { filter: (event) => event.action.startsWith('oauth_client'), diff --git a/packages/manager/src/queries/nodebalancers.ts b/packages/manager/src/queries/nodebalancers.ts index 0cfc7afdc16..e6270adceef 100644 --- a/packages/manager/src/queries/nodebalancers.ts +++ b/packages/manager/src/queries/nodebalancers.ts @@ -1,10 +1,4 @@ import { - CreateNodeBalancerConfig, - CreateNodeBalancerPayload, - Firewall, - NodeBalancer, - NodeBalancerConfig, - NodeBalancerStats, createNodeBalancer, createNodeBalancerConfig, deleteNodeBalancer, @@ -25,126 +19,178 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query'; -import { DateTime } from 'luxon'; -import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { queryKey as firewallsQueryKey } from 'src/queries/firewalls'; -import { parseAPIDate } from 'src/utilities/date'; import { getAll } from 'src/utilities/getAll'; import { queryPresets } from './base'; -import { itemInListCreationHandler, itemInListMutationHandler } from './base'; import { profileQueries } from './profile/profile'; import type { APIError, + CreateNodeBalancerConfig, + CreateNodeBalancerPayload, Filter, + Firewall, + NodeBalancer, + NodeBalancerConfig, + NodeBalancerStats, Params, PriceType, ResourcePage, -} from '@linode/api-v4/lib/types'; - -export const queryKey = 'nodebalancers'; - -export const NODEBALANCER_STATS_NOT_READY_API_MESSAGE = - 'Stats are unavailable at this time.'; +} from '@linode/api-v4'; +import type { EventHandlerData } from 'src/hooks/useEventHandlers'; const getAllNodeBalancerTypes = () => getAll((params) => getNodeBalancerTypes(params))().then( (results) => results.data ); -export const typesQueries = createQueryKeys('types', { +export const getAllNodeBalancerConfigs = (id: number) => + getAll((params) => + getNodeBalancerConfigs(id, params) + )().then((data) => data.data); + +export const getAllNodeBalancers = () => + getAll((params) => getNodeBalancers(params))().then( + (data) => data.data + ); + +export const nodebalancerQueries = createQueryKeys('nodebalancers', { + nodebalancer: (id: number) => ({ + contextQueries: { + configurations: { + queryFn: () => getAllNodeBalancerConfigs(id), + queryKey: null, + }, + firewalls: { + queryFn: () => getNodeBalancerFirewalls(id), + queryKey: null, + }, + stats: { + queryFn: () => getNodeBalancerStats(id), + queryKey: null, + }, + }, + queryFn: () => getNodeBalancer(id), + queryKey: [id], + }), nodebalancers: { + contextQueries: { + all: { + queryFn: getAllNodeBalancers, + queryKey: null, + }, + infinite: (filter: Filter = {}) => ({ + queryFn: ({ pageParam }) => + getNodeBalancers({ page: pageParam, page_size: 25 }, filter), + queryKey: [filter], + }), + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getNodeBalancers(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, + types: { queryFn: getAllNodeBalancerTypes, queryKey: null, }, }); -const getIsTooEarlyForStats = (created?: string) => { - if (!created) { - return false; - } - - return parseAPIDate(created) > DateTime.local().minus({ minutes: 5 }); -}; - -export const useNodeBalancerStats = (id: number, created?: string) => { - return useQuery( - [queryKey, 'nodebalancer', id, 'stats'], - getIsTooEarlyForStats(created) - ? () => - Promise.reject([{ reason: NODEBALANCER_STATS_NOT_READY_API_MESSAGE }]) - : () => getNodeBalancerStats(id), - // We need to disable retries because the API will - // error if stats are not ready. If the default retry policy - // is used, a "stats not ready" state can't be shown because the - // query is still trying to request. - { refetchInterval: 20000, retry: false } - ); +export const useNodeBalancerStatsQuery = (id: number) => { + return useQuery({ + ...nodebalancerQueries.nodebalancer(id)._ctx.stats, + refetchInterval: 20000, + retry: false, + }); }; export const useNodeBalancersQuery = (params: Params, filter: Filter) => - useQuery, APIError[]>( - [queryKey, 'paginated', params, filter], - () => getNodeBalancers(params, filter), - { keepPreviousData: true } - ); + useQuery, APIError[]>({ + ...nodebalancerQueries.nodebalancers._ctx.paginated(params, filter), + keepPreviousData: true, + }); export const useNodeBalancerQuery = (id: number, enabled = true) => - useQuery( - [queryKey, 'nodebalancer', id], - () => getNodeBalancer(id), - { enabled } - ); + useQuery({ + ...nodebalancerQueries.nodebalancer(id), + enabled, + }); export const useNodebalancerUpdateMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation>( - (data) => updateNodeBalancer(id, data), - { - onSuccess(data) { - queryClient.invalidateQueries([queryKey]); - queryClient.setQueryData([queryKey, 'nodebalancer', id], data); - }, - } - ); + return useMutation>({ + mutationFn: (data) => updateNodeBalancer(id, data), + onSuccess(nodebalancer) { + // Invalidate paginated stores + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancers.queryKey, + }); + // Update the NodeBalancer store + queryClient.setQueryData( + nodebalancerQueries.nodebalancer(id).queryKey, + nodebalancer + ); + }, + }); }; export const useNodebalancerDeleteMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => deleteNodeBalancer(id), { + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteNodeBalancer(id), onSuccess() { - queryClient.removeQueries([queryKey, 'nodebalancer', id]); - queryClient.invalidateQueries([queryKey]); + // Remove NodeBalancer queries for this specific NodeBalancer + queryClient.removeQueries({ + queryKey: nodebalancerQueries.nodebalancer(id).queryKey, + }); + // Invalidate paginated stores + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancers.queryKey, + }); }, }); }; export const useNodebalancerCreateMutation = () => { const queryClient = useQueryClient(); - return useMutation( - createNodeBalancer, - { - onSuccess(data) { - queryClient.invalidateQueries([queryKey]); - queryClient.setQueryData([queryKey, 'nodebalancer', data.id], data); - // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries(profileQueries.grants.queryKey); - }, - } - ); + return useMutation({ + mutationFn: createNodeBalancer, + onSuccess(nodebalancer) { + // Invalidate paginated stores + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancers.queryKey, + }); + // Prime the cache for this specific NodeBalancer + queryClient.setQueryData( + nodebalancerQueries.nodebalancer(nodebalancer.id).queryKey, + nodebalancer + ); + // If a restricted user creates an entity, we must make sure grants are up to date. + queryClient.invalidateQueries(profileQueries.grants.queryKey); + }, + }); }; export const useNodebalancerConfigCreateMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation( - (data) => createNodeBalancerConfig(id, data), - itemInListCreationHandler( - [queryKey, 'nodebalancer', id, 'configs'], - queryClient - ) - ); + return useMutation({ + mutationFn: (data) => createNodeBalancerConfig(id, data), + onSuccess(config) { + // Append new config to the configurations list + queryClient.setQueryData( + nodebalancerQueries.nodebalancer(id)._ctx.configurations.queryKey, + (previousData) => { + if (!previousData) { + return [config]; + } + return [...previousData, config]; + } + ); + }, + }); }; interface CreateNodeBalancerConfigWithConfig @@ -158,109 +204,128 @@ export const useNodebalancerConfigUpdateMutation = (nodebalancerId: number) => { NodeBalancerConfig, APIError[], CreateNodeBalancerConfigWithConfig - >( - ({ configId, ...data }) => + >({ + mutationFn: ({ configId, ...data }) => updateNodeBalancerConfig(nodebalancerId, configId, data), - itemInListMutationHandler( - [queryKey, 'nodebalancer', nodebalancerId, 'configs'], - queryClient - ) - ); + onSuccess(config) { + // Update the config within the configs list + queryClient.setQueryData( + nodebalancerQueries.nodebalancer(nodebalancerId)._ctx.configurations + .queryKey, + (previousData) => { + if (!previousData) { + return [config]; + } + const indexOfConfig = previousData.findIndex( + (c) => c.id === config.id + ); + if (indexOfConfig === -1) { + return [...previousData, config]; + } + const newConfigs = [...previousData]; + newConfigs[indexOfConfig] = config; + return newConfigs; + } + ); + }, + }); }; export const useNodebalancerConfigDeleteMutation = (nodebalancerId: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[], { configId: number }>( - ({ configId }) => deleteNodeBalancerConfig(nodebalancerId, configId), - { - onSuccess(_, vars) { - queryClient.setQueryData( - [queryKey, 'nodebalancer', nodebalancerId, 'configs'], - (oldData) => { - return (oldData ?? []).filter( - (config) => config.id !== vars.configId - ); - } - ); - }, - } - ); + return useMutation<{}, APIError[], { configId: number }>({ + mutationFn: ({ configId }) => + deleteNodeBalancerConfig(nodebalancerId, configId), + onSuccess(_, vars) { + queryClient.setQueryData( + nodebalancerQueries.nodebalancer(nodebalancerId)._ctx.configurations + .queryKey, + (oldData) => { + return (oldData ?? []).filter( + (config) => config.id !== vars.configId + ); + } + ); + }, + }); }; export const useAllNodeBalancerConfigsQuery = (id: number) => - useQuery( - [queryKey, 'nodebalanacer', id, 'configs'], - () => getAllNodeBalancerConfigs(id), - { refetchInterval: 20000 } - ); - -export const getAllNodeBalancerConfigs = (id: number) => - getAll((params) => - getNodeBalancerConfigs(id, params) - )().then((data) => data.data); - -export const getAllNodeBalancers = () => - getAll((params) => getNodeBalancers(params))().then( - (data) => data.data - ); + useQuery({ + ...nodebalancerQueries.nodebalancer(id)._ctx.configurations, + refetchInterval: 20000, + }); // Please don't use export const useAllNodeBalancersQuery = (enabled = true) => - useQuery([queryKey, 'all'], getAllNodeBalancers, { + useQuery({ + ...nodebalancerQueries.nodebalancers._ctx.all, enabled, }); export const useInfiniteNodebalancersQuery = (filter: Filter) => - useInfiniteQuery, APIError[]>( - [queryKey, 'infinite', filter], - ({ pageParam }) => - getNodeBalancers({ page: pageParam, page_size: 25 }, filter), - { - getNextPageParam: ({ page, pages }) => { - if (page === pages) { - return undefined; - } - return page + 1; - }, - } + useInfiniteQuery, APIError[]>({ + ...nodebalancerQueries.nodebalancers._ctx.infinite(filter), + getNextPageParam: ({ page, pages }) => { + if (page === pages) { + return undefined; + } + return page + 1; + }, + }); + +export const useNodeBalancersFirewallsQuery = (nodebalancerId: number) => + useQuery, APIError[]>( + nodebalancerQueries.nodebalancer(nodebalancerId)._ctx.firewalls ); -export const nodebalanacerEventHandler = ({ +export const useNodeBalancerTypesQuery = () => + useQuery({ + ...queryPresets.oneTimeFetch, + ...nodebalancerQueries.types, + }); + +export const nodebalancerEventHandler = ({ event, queryClient, }: EventHandlerData) => { + const nodebalancerId = event.entity?.id; + + if (event.action.startsWith('nodebalancer_node')) { + // We don't store NodeBalancer nodes is React Query currently, so just skip these events + return; + } + + if (nodebalancerId === undefined) { + // Ignore events that don't have an associated NodeBalancer + return; + } + if (event.action.startsWith('nodebalancer_config')) { - queryClient.invalidateQueries([ - queryKey, - 'nodebalancer', - event.entity!.id, - 'configs', - ]); - } else if (event.action.startsWith('nodebalancer_delete')) { - queryClient.invalidateQueries([firewallsQueryKey]); + // If the event is about a NodeBalancer's configs, just invalidate the configs + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(nodebalancerId)._ctx + .configurations.queryKey, + }); } else { - queryClient.invalidateQueries([queryKey, 'all']); - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.invalidateQueries([queryKey, 'infinite']); - if (event.entity?.id) { - queryClient.invalidateQueries([ - queryKey, - 'nodebalancer', - event.entity.id, - ]); + // If we've made it here, the event is about a NodeBalancer + + // Invalidate the specific NodeBalancer + queryClient.invalidateQueries({ + exact: true, + queryKey: nodebalancerQueries.nodebalancer(nodebalancerId).queryKey, + }); + + // Invalidate all paginated lists + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancers.queryKey, + }); + + if (event.action === 'nodebalancer_delete') { + // A deleted NodeBalancer may have been associated with a Firewall, + // so we want to invalidate Firewalls to reflect the device being removed. + // @tood: Optimize this heavy invalidation + queryClient.invalidateQueries({ queryKey: [firewallsQueryKey] }); } } }; - -export const useNodeBalancersFirewallsQuery = (nodebalancerId: number) => - useQuery, APIError[]>( - [queryKey, 'nodebalancer', nodebalancerId, 'firewalls'], - () => getNodeBalancerFirewalls(nodebalancerId), - queryPresets.oneTimeFetch - ); - -export const useNodeBalancerTypesQuery = () => - useQuery({ - ...queryPresets.oneTimeFetch, - ...typesQueries.nodebalancers, - }); From c7bf93ce3b9b8714efc168f79b58d87636a7148c Mon Sep 17 00:00:00 2001 From: cliu-akamai <126020611+cliu-akamai@users.noreply.github.com> Date: Wed, 12 Jun 2024 14:56:21 -0400 Subject: [PATCH 080/163] test: [M3-7954] - Cypress integration test to add SSH key via Profile page (#10477) * M3-7954 Add Cypress test to add SSH key via Profile page * Fixed comments * Added changeset: Cypress integration test to add SSH key via Profile page --- .../pr-10477-tests-1717448262964.md | 5 + .../cypress/e2e/core/account/ssh-keys.spec.ts | 172 ++++++++++++++++++ .../cypress/support/constants/account.ts | 6 + .../cypress/support/intercepts/profile.ts | 34 +++- 4 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 packages/manager/.changeset/pr-10477-tests-1717448262964.md create mode 100644 packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts diff --git a/packages/manager/.changeset/pr-10477-tests-1717448262964.md b/packages/manager/.changeset/pr-10477-tests-1717448262964.md new file mode 100644 index 00000000000..19fd7139b94 --- /dev/null +++ b/packages/manager/.changeset/pr-10477-tests-1717448262964.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Cypress integration test to add SSH key via Profile page ([#10477](https://github.com/linode/manager/pull/10477)) diff --git a/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts b/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts new file mode 100644 index 00000000000..59b34a59101 --- /dev/null +++ b/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts @@ -0,0 +1,172 @@ +import { sshKeyFactory } from 'src/factories'; +import { + mockCreateSSHKey, + mockCreateSSHKeyError, + mockGetSSHKeys, +} from 'support/intercepts/profile'; +import { ui } from 'support/ui'; +import { randomLabel, randomString } from 'support/util/random'; +import { sshFormatErrorMessage } from 'support/constants/account'; + +describe('SSH keys', () => { + /* + * - Vaildates SSH key creation flow using mock data. + * - Confirms that the drawer opens when clicking. + * - Confirms that a form validation error appears when the label or public key is not present. + * - Confirms UI flow when user enters incorrect public key. + * - Confirms UI flow when user clicks "Cancel". + * - Confirms UI flow when user creates a new SSH key. + */ + it('adds an SSH key via Profile page as expected', () => { + const randomKey = randomString(400, { + uppercase: true, + lowercase: true, + numbers: true, + spaces: false, + symbols: false, + }); + const mockSSHKey = sshKeyFactory.build({ + label: randomLabel(), + ssh_key: `ssh-rsa e2etestkey${randomKey} e2etest@linode`, + }); + + mockGetSSHKeys([]).as('getSSHKeys'); + + // Navigate to SSH key landing page, click the "Add an SSH Key" button. + cy.visitWithLogin('/profile/keys'); + cy.wait('@getSSHKeys'); + + // When a user clicks "Add an SSH Key" button on SSH key landing page (/profile/keys), the "Add an SSH Key" drawer opens + ui.button + .findByTitle('Add an SSH Key') + .should('be.visible') + .should('be.enabled') + .click(); + ui.drawer + .findByTitle('Add SSH Key') + .should('be.visible') + .within(() => { + // When a user tries to create an SSH key without a label, a form validation error appears + ui.button + .findByTitle('Add Key') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('Label is required.'); + + // When a user tries to create an SSH key without the SSH Public Key, a form validation error appears + cy.get('[id="label"]').clear().type(mockSSHKey.label); + ui.button + .findByTitle('Add Key') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findAllByText(sshFormatErrorMessage).should('be.visible'); + + // An alert displays when the format of SSH key is incorrect + cy.get('[id="ssh-public-key"]').clear().type('WrongFormatSshKey'); + ui.button + .findByTitle('Add Key') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findAllByText(sshFormatErrorMessage).should('be.visible'); + + cy.get('[id="ssh-public-key"]').clear().type(mockSSHKey.ssh_key); + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + // No new key is added when cancelling. + cy.findAllByText(mockSSHKey.label).should('not.exist'); + + mockGetSSHKeys([mockSSHKey]).as('getSSHKeys'); + mockCreateSSHKey(mockSSHKey).as('createSSHKey'); + + ui.button + .findByTitle('Add an SSH Key') + .should('be.visible') + .should('be.enabled') + .click(); + ui.drawer + .findByTitle('Add SSH Key') + .should('be.visible') + .within(() => { + // When a user clicks "Cancel" or the drawer's close button, and then clicks "Add an SSH Key" again, the content they previously entered into the form is erased + cy.get('[id="label"]').should('be.empty'); + cy.get('[id="ssh-public-key"]').should('be.empty'); + + // Create a new ssh key + cy.get('[id="label"]').clear().type(mockSSHKey.label); + cy.get('[id="ssh-public-key"]').clear().type(mockSSHKey.ssh_key); + ui.button + .findByTitle('Add Key') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@getSSHKeys'); + + // When a user creates an SSH key, a toast notification appears that says "Successfully created SSH key." + ui.toast.assertMessage('Successfully created SSH key.'); + + // When a user creates an SSH key, the list of SSH keys for each user updates to show the new key for the signed in user + cy.findAllByText(mockSSHKey.label).should('be.visible'); + }); + + /* + * - Vaildates SSH key creation error flow using mock data. + * - Confirms that a useful error message is displayed on the form when receiving an API response error. + */ + it('shows an error message when fail to add an SSH key', () => { + const errorMessage = 'failed to add an SSH key.'; + const sshKeyLabel = randomLabel(); + const randomKey = randomString(400, { + uppercase: true, + lowercase: true, + numbers: true, + spaces: false, + symbols: false, + }); + const sshPublicKey = `ssh-rsa e2etestkey${randomKey} e2etest@linode`; + + mockCreateSSHKeyError(errorMessage).as('createSSHKeyError'); + mockGetSSHKeys([]).as('getSSHKeys'); + + // Navigate to SSH key landing page, click the "Add an SSH Key" button. + cy.visitWithLogin('/profile/keys'); + cy.wait('@getSSHKeys'); + + // When a user clicks "Add an SSH Key" button on SSH key landing page (/profile/keys), the "Add an SSH Key" drawer opens + ui.button + .findByTitle('Add an SSH Key') + .should('be.visible') + .should('be.enabled') + .click(); + ui.drawer + .findByTitle('Add SSH Key') + .should('be.visible') + .within(() => { + // When a user clicks "Cancel" or the drawer's close button, and then clicks "Add an SSH Key" again, the content they previously entered into the form is erased + cy.get('[id="label"]').should('be.empty'); + cy.get('[id="ssh-public-key"]').should('be.empty'); + + // Create a new ssh key + cy.get('[id="label"]').clear().type(sshKeyLabel); + cy.get('[id="ssh-public-key"]').clear().type(sshPublicKey); + ui.button + .findByTitle('Add Key') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@createSSHKeyError'); + + // When the API responds with an error (e.g. a 400 response), the API response error message is displayed on the form + cy.findByText(errorMessage); + }); +}); diff --git a/packages/manager/cypress/support/constants/account.ts b/packages/manager/cypress/support/constants/account.ts index 7ca6940fbf1..46396b7d895 100644 --- a/packages/manager/cypress/support/constants/account.ts +++ b/packages/manager/cypress/support/constants/account.ts @@ -12,3 +12,9 @@ may not be able be restored.'; export const cancellationPaymentErrorMessage = 'We were unable to charge your credit card for services rendered. \ We cannot cancel this account until the balance has been paid.'; + +/** + * Error message that appears when typing an error SSH key. + */ +export const sshFormatErrorMessage = + 'SSH Key key-type must be ssh-dss, ssh-rsa, ecdsa-sha2-nistp, ssh-ed25519, or sk-ecdsa-sha2-nistp256.'; diff --git a/packages/manager/cypress/support/intercepts/profile.ts b/packages/manager/cypress/support/intercepts/profile.ts index f9053602749..11aee4b2c76 100644 --- a/packages/manager/cypress/support/intercepts/profile.ts +++ b/packages/manager/cypress/support/intercepts/profile.ts @@ -13,9 +13,9 @@ import type { Profile, SecurityQuestionsData, SecurityQuestionsPayload, - SSHKey, Token, UserPreferences, + SSHKey, } from '@linode/api-v4'; /** @@ -421,16 +421,40 @@ export const mockGetSSHKey = (sshKey: SSHKey): Cypress.Chainable => { }; /** - * Intercepts POST request to create an SSH key and mocks the response. + * Intercepts POST request to create an SSH key. * - * @param sshKey - SSH key object with which to mock response. + * @returns Cypress chainable. + */ +export const interceptCreateSSHKey = (): Cypress.Chainable => { + return cy.intercept('POST', apiMatcher(`profile/sshkeys*`)); +}; + +/** + * Intercepts POST request to create an SSH key and mocks response. + * + * @param sshKey - An SSH key with which to create. * * @returns Cypress chainable. */ export const mockCreateSSHKey = (sshKey: SSHKey): Cypress.Chainable => { + return cy.intercept('POST', apiMatcher(`profile/sshkeys`), sshKey); +}; + +/** + * Intercepts POST request to create an SSH key and mocks an API error response. + * + * @param errorMessage - Error message to include in mock error response. + * @param status - HTTP status for mock error response. + * + * @returns Cypress chainable. + */ +export const mockCreateSSHKeyError = ( + errorMessage: string, + status: number = 400 +): Cypress.Chainable => { return cy.intercept( 'POST', - apiMatcher('/profile/sshkeys'), - makeResponse(sshKey) + apiMatcher('profile/sshkeys'), + makeErrorResponse(errorMessage, status) ); }; From 225a1759ce00f282c1399014ceee8bc31ce39bf7 Mon Sep 17 00:00:00 2001 From: venkymano Date: Thu, 13 Jun 2024 18:58:41 +0530 Subject: [PATCH 081/163] upcoming: [DI-18829] - Resources MultiSelect component in cloudpulse global filters view (#10539) Co-authored-by: vmangalr@akamai.com --- ...r-10539-upcoming-features-1717414240425.md | 5 + .../CloudPulse/Overview/GlobalFilters.tsx | 32 +++- ...st.tsx => CloudPulseRegionSelect.test.tsx} | 4 +- ...nSelect.tsx => CloudPulseRegionSelect.tsx} | 4 +- .../shared/CloudPulseResourcesSelect.test.tsx | 160 ++++++++++++++++++ .../shared/CloudPulseResourcesSelect.tsx | 81 +++++++++ ...tsx => CloudPulseTimeRangeSelect.test.tsx} | 2 +- ...lect.tsx => CloudPulseTimeRangeSelect.tsx} | 0 packages/manager/src/queries/aclb/requests.ts | 14 ++ .../src/queries/cloudpulse/resources.ts | 53 ++++++ 10 files changed, 347 insertions(+), 8 deletions(-) create mode 100644 packages/manager/.changeset/pr-10539-upcoming-features-1717414240425.md rename packages/manager/src/features/CloudPulse/shared/{RegionSelect.test.tsx => CloudPulseRegionSelect.test.tsx} (76%) rename packages/manager/src/features/CloudPulse/shared/{RegionSelect.tsx => CloudPulseRegionSelect.tsx} (91%) create mode 100644 packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx create mode 100644 packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx rename packages/manager/src/features/CloudPulse/shared/{TimeRangeSelect.test.tsx => CloudPulseTimeRangeSelect.test.tsx} (95%) rename packages/manager/src/features/CloudPulse/shared/{TimeRangeSelect.tsx => CloudPulseTimeRangeSelect.tsx} (100%) create mode 100644 packages/manager/src/queries/aclb/requests.ts create mode 100644 packages/manager/src/queries/cloudpulse/resources.ts diff --git a/packages/manager/.changeset/pr-10539-upcoming-features-1717414240425.md b/packages/manager/.changeset/pr-10539-upcoming-features-1717414240425.md new file mode 100644 index 00000000000..69f5bc6565a --- /dev/null +++ b/packages/manager/.changeset/pr-10539-upcoming-features-1717414240425.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Resources MultiSelect component in cloudpulse global filters view ([#10539](https://github.com/linode/manager/pull/10539)) diff --git a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx index f5ad47c691d..c134e02cd63 100644 --- a/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Overview/GlobalFilters.tsx @@ -2,10 +2,12 @@ import { styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; -import { WithStartAndEnd } from 'src/features/Longview/request.types'; +import { CloudPulseRegionSelect } from '../shared/CloudPulseRegionSelect'; +import { CloudPulseResourcesSelect } from '../shared/CloudPulseResourcesSelect'; +import { CloudPulseTimeRangeSelect } from '../shared/CloudPulseTimeRangeSelect'; -import { CloudPulseRegionSelect } from '../shared/RegionSelect'; -import { CloudPulseTimeRangeSelect } from '../shared/TimeRangeSelect'; +import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; +import type { WithStartAndEnd } from 'src/features/Longview/request.types'; export interface GlobalFilterProperties { handleAnyFilterChange(filters: FiltersObject): undefined | void; @@ -26,6 +28,7 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { }); const [selectedRegion, setRegion] = React.useState(); + const [, setResources] = React.useState(); // removed the unused variable, this will be used later point of time React.useEffect(() => { const triggerGlobalFilterChange = () => { const globalFilters: FiltersObject = { @@ -54,6 +57,13 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { setRegion(region); }, []); + const handleResourcesSelection = React.useCallback( + (resources: CloudPulseResources[]) => { + setResources(resources); + }, + [] + ); + return ( @@ -62,7 +72,15 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { handleRegionChange={handleRegionChange} /> - + + + + + ({ alignItems: 'end', boxSizing: 'border-box', diff --git a/packages/manager/src/features/CloudPulse/shared/RegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx similarity index 76% rename from packages/manager/src/features/CloudPulse/shared/RegionSelect.test.tsx rename to packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx index c1e24bb8f84..6f17a20649d 100644 --- a/packages/manager/src/features/CloudPulse/shared/RegionSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.test.tsx @@ -2,8 +2,8 @@ import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { CloudPulseRegionSelectProps } from './RegionSelect'; -import { CloudPulseRegionSelect } from './RegionSelect'; +import { CloudPulseRegionSelectProps } from './CloudPulseRegionSelect'; +import { CloudPulseRegionSelect } from './CloudPulseRegionSelect'; const props: CloudPulseRegionSelectProps = { handleRegionChange: vi.fn(), diff --git a/packages/manager/src/features/CloudPulse/shared/RegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx similarity index 91% rename from packages/manager/src/features/CloudPulse/shared/RegionSelect.tsx rename to packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index 176f4bc37a8..87c9b675bc0 100644 --- a/packages/manager/src/features/CloudPulse/shared/RegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -14,7 +14,9 @@ export const CloudPulseRegionSelect = React.memo( const [selectedRegion, setRegion] = React.useState(); React.useEffect(() => { - props.handleRegionChange(selectedRegion); + if (selectedRegion) { + props.handleRegionChange(selectedRegion); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedRegion]); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx new file mode 100644 index 00000000000..dc745704d43 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx @@ -0,0 +1,160 @@ +import { fireEvent, screen } from '@testing-library/react'; +import * as React from 'react'; + +import { linodeFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { CloudPulseResourcesSelect } from './CloudPulseResourcesSelect'; + +const queryMocks = vi.hoisted(() => ({ + useResourcesQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/cloudpulse/resources', async () => { + const actual = await vi.importActual('src/queries/cloudpulse/resources'); + return { + ...actual, + useResourcesQuery: queryMocks.useResourcesQuery, + }; +}); + +const mockResourceHandler = vi.fn(); +const SELECT_ALL = 'Select All'; +const ARIA_SELECTED = 'aria-selected' + +describe('CloudPulseResourcesSelect component tests', () => { + it('should render disabled component if the the props are undefined or regions and service type does not have any resources', () => { + const { getByPlaceholderText, getByTestId } = renderWithTheme( + + ); + expect(getByTestId('Resource-select')).toBeInTheDocument(); + expect(getByPlaceholderText('Select Resources')).toBeInTheDocument(); + }), + it('should render resources happy path', () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodeFactory.buildList(2), + isError: false, + isLoading: false, + status: 'success', + }); + renderWithTheme( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + expect( + screen.getByRole('option', { + name: 'linode-0', + }) + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { + name: 'linode-1', + }) + ).toBeInTheDocument(); + }); + + it('should be able to select all resources', () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodeFactory.buildList(2), + isError: false, + isLoading: false, + status: 'success', + }); + renderWithTheme( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + fireEvent.click(screen.getByRole('option', { name: SELECT_ALL })); + expect( + screen.getByRole('option', { + name: 'linode-2', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + screen.getByRole('option', { + name: 'linode-3', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + }); + + it('should be able to deselect the selected resources', () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodeFactory.buildList(2), + isError: false, + isLoading: false, + status: 'success', + }); + renderWithTheme( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + fireEvent.click(screen.getByRole('option', { name: SELECT_ALL })); + fireEvent.click(screen.getByRole('option', { name: 'Deselect All' })); + expect( + screen.getByRole('option', { + name: 'linode-4', + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + expect( + screen.getByRole('option', { + name: 'linode-5', + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + }); + + it('should select multiple resources', () => { + queryMocks.useResourcesQuery.mockReturnValue({ + data: linodeFactory.buildList(3), + isError: false, + isLoading: false, + status: 'success', + }); + renderWithTheme( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Open' })); + fireEvent.click(screen.getByRole('option', { name: 'linode-6' })); + fireEvent.click(screen.getByRole('option', { name: 'linode-7' })); + + expect( + screen.getByRole('option', { + name: 'linode-6', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + screen.getByRole('option', { + name: 'linode-7', + }) + ).toHaveAttribute(ARIA_SELECTED, 'true'); + expect( + screen.getByRole('option', { + name: 'linode-8', + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + expect( + screen.getByRole('option', { + name: 'Select All', + }) + ).toHaveAttribute(ARIA_SELECTED, 'false'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx new file mode 100644 index 00000000000..b04b1c7b28b --- /dev/null +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; + +export interface CloudPulseResources { + id: number; + label: string; + region?: string; // usually linodes are associated with only one region + regions?: string[]; // aclb are associated with multiple regions +} + +export interface CloudPulseResourcesSelectProps { + defaultSelection?: number[]; + handleResourcesSelection: (resources: CloudPulseResources[]) => void; + placeholder?: string; + region: string | undefined; + resourceType: string | undefined; +} + +export const CloudPulseResourcesSelect = React.memo( + (props: CloudPulseResourcesSelectProps) => { + const [selectedResource, setResources] = React.useState< + CloudPulseResources[] + >([]); + const { data: resources, isLoading } = useResourcesQuery( + props.region && props.resourceType ? true : false, + props.resourceType, + {}, + { region: props.region } + ); + + const getResourcesList = (): CloudPulseResources[] => { + return resources && resources.length > 0 ? resources : []; + }; + + React.useEffect(() => { + const defaultResources = resources?.filter((instance) => + props.defaultSelection?.includes(instance.id) + ); + + if (defaultResources && defaultResources.length > 0) { + setResources(defaultResources); + props.handleResourcesSelection(defaultResources!); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resources, props.region]); // only on any resources or region change, select defaults if any + + return ( + { + setResources(resourceSelections); + props.handleResourcesSelection(resourceSelections); + }} + autoHighlight + clearOnBlur + data-testid={'Resource-select'} + disabled={!props.region || !props.resourceType || isLoading} + isOptionEqualToValue={(option, value) => option.label === value.label} + label="" + limitTags={2} + multiple + options={getResourcesList()} + placeholder={props.placeholder ? props.placeholder : 'Select Resources'} + value={selectedResource ? selectedResource : []} + /> + ); + }, + compareProps // we can re-render this component, on only region and resource type changes +); + +function compareProps( + oldProps: CloudPulseResourcesSelectProps, + newProps: CloudPulseResourcesSelectProps +) { + return ( + oldProps.region == newProps.region && + oldProps.resourceType == newProps.resourceType + ); +} diff --git a/packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.test.tsx similarity index 95% rename from packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.test.tsx rename to packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.test.tsx index c7fd4c7020f..5d6bc306e72 100644 --- a/packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.test.tsx @@ -1,6 +1,6 @@ import { DateTime } from 'luxon'; -import { generateStartTime } from './TimeRangeSelect'; +import { generateStartTime } from './CloudPulseTimeRangeSelect'; describe('Utility Functions', () => { it('should create values as functions that return the correct datetime', () => { diff --git a/packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx similarity index 100% rename from packages/manager/src/features/CloudPulse/shared/TimeRangeSelect.tsx rename to packages/manager/src/features/CloudPulse/shared/CloudPulseTimeRangeSelect.tsx diff --git a/packages/manager/src/queries/aclb/requests.ts b/packages/manager/src/queries/aclb/requests.ts new file mode 100644 index 00000000000..197d4f0d2af --- /dev/null +++ b/packages/manager/src/queries/aclb/requests.ts @@ -0,0 +1,14 @@ +import { Filter, Loadbalancer, Params, getLoadbalancers } from '@linode/api-v4'; + +import { getAll } from 'src/utilities/getAll'; + +export const getAllLoadbalancers = ( + passedParams: Params = {}, + passedFilter: Filter = {} +) => + getAll((params, filter) => + getLoadbalancers( + { ...params, ...passedParams }, + { ...filter, ...passedFilter } + ) + )().then((data) => data.data); diff --git a/packages/manager/src/queries/cloudpulse/resources.ts b/packages/manager/src/queries/cloudpulse/resources.ts new file mode 100644 index 00000000000..b8437f577f2 --- /dev/null +++ b/packages/manager/src/queries/cloudpulse/resources.ts @@ -0,0 +1,53 @@ +import { Filter, Params } from '@linode/api-v4'; +import { useQuery } from '@tanstack/react-query'; + +import { CloudPulseResources } from 'src/features/CloudPulse/shared/CloudPulseResourcesSelect'; + +import { getAllLoadbalancers } from '../aclb/requests'; +import { getAllLinodesRequest } from '../linodes/requests'; +import { volumeQueries } from '../volumes/volumes'; + +// in this we don't need to define our own query factory, we will reuse existing query factory implementation from services like in volumes.ts, linodes.ts etc +export const QueryFactoryByResources = ( + resourceType: string | undefined, + params?: Params, + filters?: Filter +) => { + switch (resourceType) { + case 'linode': + return { + queryFn: () => getAllLinodesRequest(params, filters), // since we don't have query factory implementation, in linodes.ts, once it is ready we will reuse that, untill then we will use same query keys + queryKey: ['linodes', params, filters], + }; + case 'volumes': + return volumeQueries.lists._ctx.all(params, filters); // in this we don't need to define our own query factory, we will reuse existing implementation in volumes.ts + case 'aclb': + return { + queryFn: () => getAllLoadbalancers(params, filters), // since we don't have query factory implementation, in loadbalancer.ts, once it is ready we will reuse that, untill then we will use same query keys + queryKey: ['loadbalancers', params, filters], + }; + default: + return volumeQueries.lists._ctx.all(params, filters); // default to volumes + } +}; + +export const useResourcesQuery = ( + enabled = false, + resourceType: string | undefined, + params?: Params, + filters?: Filter +) => + useQuery({ + ...QueryFactoryByResources(resourceType, params, filters), + enabled, + select: (resources) => { + return resources.map((resource) => { + return { + id: resource.id, + label: resource.label, + region: resource.region, + regions: resource.regions ? resource.regions : [], + }; + }); + }, + }); From bb60d3a523443541c6a493ce0f266e3158382328 Mon Sep 17 00:00:00 2001 From: tbaka <48444023+tbaka@users.noreply.github.com> Date: Thu, 13 Jun 2024 09:30:34 -0500 Subject: [PATCH 082/163] fix: [OCA: 1382] - Correct docs urls for Apache Kafka Cluster and Couchbase Cluster (#10569) * fix doc urls * Added changeset: correct doc urls * Update packages/manager/.changeset/pr-10569-fixed-1718134339100.md Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --------- Co-authored-by: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- packages/manager/.changeset/pr-10569-fixed-1718134339100.md | 5 +++++ packages/manager/src/features/OneClickApps/oneClickAppsv2.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-10569-fixed-1718134339100.md diff --git a/packages/manager/.changeset/pr-10569-fixed-1718134339100.md b/packages/manager/.changeset/pr-10569-fixed-1718134339100.md new file mode 100644 index 00000000000..98ae6b50e0c --- /dev/null +++ b/packages/manager/.changeset/pr-10569-fixed-1718134339100.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Marketplace docs urls for Apache Kafka Cluster and Couchbase Cluster ([#10569](https://github.com/linode/manager/pull/10569)) diff --git a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts index e447e0246f6..2999f25419f 100644 --- a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts +++ b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts @@ -2472,7 +2472,7 @@ export const oneClickApps: Record = { related_guides: [ { href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/couchbase/', + 'https://www.linode.com/docs/products/tools/marketplace/guides/couchbase-cluster/', title: 'Deploy a Couchbase Enterprise Server cluster through the Linode Marketplace', }, @@ -2495,7 +2495,7 @@ export const oneClickApps: Record = { related_guides: [ { href: - 'https://www.linode.com/docs/products/tools/marketplace/guides/apache-kafka/', + 'https://www.linode.com/docs/products/tools/marketplace/guides/apache-kafka-cluster/', title: 'Deploy an Apache Kafka cluster through the Linode Marketplace', }, ], From 2db9ca3a56c3524f731282c89642fd7df4e534e7 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Thu, 13 Jun 2024 12:44:55 -0400 Subject: [PATCH 083/163] Improve disk encryption banner test resiliency against CI test account differences (#10572) --- .../e2e/core/kubernetes/lke-landing-page.spec.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts index 734d807b6c8..24c7fcbfeb0 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts @@ -32,13 +32,17 @@ describe('LKE landing page', () => { capabilities: ['Linodes', 'Disk Encryption'], }); + const mockCluster = kubernetesClusterFactory.build(); + mockGetAccount(mockAccount).as('getAccount'); + mockGetClusters([mockCluster]).as('getClusters'); // Intercept request cy.visitWithLogin('/kubernetes/clusters'); - cy.wait('@getAccount'); + cy.wait(['@getClusters', '@getAccount']); - // Check if banner is visible + // Wait for page to load before confirming that banner is not present. + cy.findByText(mockCluster.label).should('be.visible'); cy.findByText('Disk encryption is now standard on Linodes.').should( 'not.exist' ); @@ -55,12 +59,14 @@ describe('LKE landing page', () => { const mockAccount = accountFactory.build({ capabilities: ['Linodes', 'Disk Encryption'], }); + const mockClusters = kubernetesClusterFactory.buildList(3); mockGetAccount(mockAccount).as('getAccount'); + mockGetClusters(mockClusters).as('getClusters'); // Intercept request cy.visitWithLogin('/kubernetes/clusters'); - cy.wait('@getAccount'); + cy.wait(['@getClusters', '@getAccount']); // Check if banner is visible cy.contains('Disk encryption is now standard on Linodes.').should( From d244ecd11ceeb4c920e67f0ea6b9d3972585fc10 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Thu, 13 Jun 2024 13:41:12 -0400 Subject: [PATCH 084/163] test: Improve stability of StackScript pagination test (#10574) * Improve stability of StackScript pagination test * Add changeset --- .../pr-10574-tests-1718222529272.md | 5 ++ .../smoke-community-stackscrips.spec.ts | 50 +++++++++++-------- 2 files changed, 33 insertions(+), 22 deletions(-) create mode 100644 packages/manager/.changeset/pr-10574-tests-1718222529272.md diff --git a/packages/manager/.changeset/pr-10574-tests-1718222529272.md b/packages/manager/.changeset/pr-10574-tests-1718222529272.md new file mode 100644 index 00000000000..3c7eadd5dbe --- /dev/null +++ b/packages/manager/.changeset/pr-10574-tests-1718222529272.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Improve stability of StackScripts pagination test ([#10574](https://github.com/linode/manager/pull/10574)) diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts index 99f47b49309..e96645f3116 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts @@ -12,9 +12,11 @@ import { chooseRegion } from 'support/util/regions'; import { cleanUp } from 'support/util/cleanup'; import { interceptCreateLinode } from 'support/intercepts/linodes'; import { getProfile } from '@linode/api-v4'; -import { Profile, StackScript } from '@linode/api-v4'; +import { Profile } from '@linode/api-v4'; import { formatDate } from '@src/utilities/formatDate'; +import type { StackScript } from '@linode/api-v4'; + const mockStackScripts: StackScript[] = [ stackScriptFactory.build({ id: 443929, @@ -188,30 +190,34 @@ describe('Community Stackscripts integration tests', () => { cy.visitWithLogin('/stackscripts/community'); cy.wait('@getStackScripts'); + // Confirm that empty state is not shown. cy.get('[data-qa-stackscript-empty-msg="true"]').should('not.exist'); cy.findByText('Automate deployment scripts').should('not.exist'); - cy.get('tr').then((value) => { - const rowCount = Cypress.$(value).length - 1; // Remove the table title row - - interceptGetStackScripts().as('getStackScripts1'); - cy.scrollTo(0, 500); - cy.wait('@getStackScripts1'); - - cy.get('tr').its('length').should('be.gt', rowCount); - - cy.get('tr').then((value) => { - const rowCount = Cypress.$(value).length - 1; - - interceptGetStackScripts().as('getStackScripts2'); - cy.get('tr') - .eq(rowCount) - .scrollIntoView({ offset: { top: 150, left: 0 } }); - cy.wait('@getStackScripts2'); - - cy.get('tr').its('length').should('be.gt', rowCount); - }); - }); + // Confirm that scrolling to the bottom of the StackScripts list causes + // pagination to occur automatically. Perform this check 3 times. + for (let i = 0; i < 3; i += 1) { + cy.findByLabelText('List of StackScripts') + .should('be.visible') + .within(() => { + // Scroll to the bottom of the StackScripts list, confirm Cloud fetches StackScripts, + // then confirm that list updates with the new StackScripts shown. + cy.get('tr').last().scrollIntoView(); + cy.wait('@getStackScripts').then((xhr) => { + const stackScripts = xhr.response?.body['data'] as + | StackScript[] + | undefined; + if (!stackScripts) { + throw new Error( + 'Unexpected response received when fetching StackScripts' + ); + } + cy.contains( + `${stackScripts[0].username} / ${stackScripts[0].label}` + ).should('be.visible'); + }); + }); + } }); /* From 47dd6a7219f956427608dd5a68a458dbefb51ed4 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 13 Jun 2024 16:08:05 -0400 Subject: [PATCH 085/163] refactor: [M3-8232] - Query Key Factory for Firewalls (#10568) * initial work * event handler * a few fixes * Added changeset: Query Key Factory for Firewalls * improve and clean up --------- Co-authored-by: Banks Nussman --- .../pr-10568-tech-stories-1718139515455.md | 5 + .../Devices/AddNodebalancerDrawer.tsx | 12 +- .../Devices/RemoveDeviceDialog.tsx | 12 +- .../Rules/FirewallRulesLanding.tsx | 14 +- .../FirewallLanding/CreateFirewallDrawer.tsx | 49 +- .../FirewallLanding/FirewallDialog.tsx | 60 +- .../FirewallLanding/FirewallLanding.tsx | 11 +- .../FirewallLanding/FirewallRow.test.tsx | 6 +- .../Firewalls/FirewallLanding/FirewallRow.tsx | 51 +- packages/manager/src/queries/firewalls.ts | 540 +++++++++++++----- .../manager/src/queries/linodes/events.ts | 8 +- .../manager/src/queries/linodes/firewalls.ts | 13 +- packages/manager/src/queries/nodebalancers.ts | 29 +- 13 files changed, 518 insertions(+), 292 deletions(-) create mode 100644 packages/manager/.changeset/pr-10568-tech-stories-1718139515455.md diff --git a/packages/manager/.changeset/pr-10568-tech-stories-1718139515455.md b/packages/manager/.changeset/pr-10568-tech-stories-1718139515455.md new file mode 100644 index 00000000000..9fa5023e72f --- /dev/null +++ b/packages/manager/.changeset/pr-10568-tech-stories-1718139515455.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Query Key Factory for Firewalls ([#10568](https://github.com/linode/manager/pull/10568)) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx index 185aedaa1cc..5667d69ada9 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx @@ -1,6 +1,4 @@ -import type { NodeBalancer } from '@linode/api-v4'; import { useTheme } from '@mui/material'; -import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { useParams } from 'react-router-dom'; @@ -16,12 +14,13 @@ import { useAddFirewallDeviceMutation, useAllFirewallsQuery, } from 'src/queries/firewalls'; -import { nodebalancerQueries } from 'src/queries/nodebalancers'; import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getEntityIdsByPermission } from 'src/utilities/grants'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; +import type { NodeBalancer } from '@linode/api-v4'; + interface Props { helperText: string; onClose: () => void; @@ -35,9 +34,8 @@ export const AddNodebalancerDrawer = (props: Props) => { const { data: grants } = useGrants(); const { data: profile } = useProfile(); const isRestrictedUser = Boolean(profile?.restricted); - const queryClient = useQueryClient(); - const { data, error, isLoading } = useAllFirewallsQuery(); + const { data, error, isLoading } = useAllFirewallsQuery(open); const firewall = data?.find((firewall) => firewall.id === Number(id)); @@ -73,10 +71,6 @@ export const AddNodebalancerDrawer = (props: Props) => { enqueueSnackbar(`NodeBalancer ${label} successfully added`, { variant: 'success', }); - queryClient.invalidateQueries({ - queryKey: nodebalancerQueries.nodebalancer(id)._ctx.firewalls - .queryKey, - }); return; } failedNodebalancers.push(selectedNodebalancers[index]); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx index ded61c873c9..98b43f25baf 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx @@ -6,7 +6,6 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Typography } from 'src/components/Typography'; import { useRemoveFirewallDeviceMutation } from 'src/queries/firewalls'; -import { queryKey as firewallQueryKey } from 'src/queries/firewalls'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; import { nodebalancerQueries } from 'src/queries/nodebalancers'; @@ -57,12 +56,9 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { // Since the linode was removed as a device, invalidate the linode-specific firewall query if (deviceType === 'linode') { - queryClient.invalidateQueries([ - linodesQueryKey, - deviceType, - device.entity.id, - 'firewalls', - ]); + queryClient.invalidateQueries({ + queryKey: [linodesQueryKey, deviceType, device.entity.id, 'firewalls'], + }); } if (deviceType === 'nodebalancer') { @@ -72,8 +68,6 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { }); } - queryClient.invalidateQueries([firewallQueryKey]); - onClose(); }; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx index d2f2c482e75..d888aa9759d 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx @@ -205,12 +205,14 @@ export const FirewallRulesLanding = React.memo((props: Props) => { if (devices) { for (const device of devices) { if (device.entity.type === 'linode') { - queryClient.invalidateQueries([ - linodesQueryKey, - device.entity.type, - device.entity.id, - 'firewalls', - ]); + queryClient.invalidateQueries({ + queryKey: [ + linodesQueryKey, + device.entity.type, + device.entity.id, + 'firewalls', + ], + }); } if (device.entity.type === 'nodebalancer') { queryClient.invalidateQueries({ diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx index 4dd3750ebb1..e24524718ab 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx @@ -1,13 +1,5 @@ /* eslint-disable jsx-a11y/anchor-is-valid */ -import { Linode } from '@linode/api-v4'; -import { - CreateFirewallPayload, - Firewall, - FirewallDeviceEntityType, -} from '@linode/api-v4/lib/firewalls'; -import { NodeBalancer } from '@linode/api-v4/lib/nodebalancers'; import { CreateFirewallSchema } from '@linode/validation/lib/firewalls.schema'; -import { useQueryClient } from '@tanstack/react-query'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -27,12 +19,7 @@ import { FIREWALL_LIMITS_CONSIDERATIONS_LINK } from 'src/constants'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { NodeBalancerSelect } from 'src/features/NodeBalancers/NodeBalancerSelect'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { - queryKey as firewallQueryKey, - useAllFirewallsQuery, - useCreateFirewall, -} from 'src/queries/firewalls'; -import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; +import { useAllFirewallsQuery, useCreateFirewall } from 'src/queries/firewalls'; import { useGrants } from 'src/queries/profile/profile'; import { sendLinodeCreateFormStepEvent } from 'src/utilities/analytics/formEventAnalytics'; import { getErrorMap } from 'src/utilities/errorUtils'; @@ -48,8 +35,14 @@ import { NODEBALANCER_CREATE_FLOW_TEXT, } from './constants'; +import type { + CreateFirewallPayload, + Firewall, + FirewallDeviceEntityType, + Linode, + NodeBalancer, +} from '@linode/api-v4'; import type { LinodeCreateType } from 'src/features/Linodes/LinodesCreate/types'; -import { nodebalancerQueries } from 'src/queries/nodebalancers'; export const READ_ONLY_DEVICES_HIDDEN_MESSAGE = 'Only services you have permission to modify are shown.'; @@ -81,10 +74,9 @@ export const CreateFirewallDrawer = React.memo( const { _hasGrant, _isRestrictedUser } = useAccountManagement(); const { data: grants } = useGrants(); const { mutateAsync } = useCreateFirewall(); - const { data } = useAllFirewallsQuery(); + const { data } = useAllFirewallsQuery(open); const { enqueueSnackbar } = useSnackbar(); - const queryClient = useQueryClient(); const location = useLocation(); const isFromLinodeCreate = location.pathname.includes('/linodes/create'); @@ -132,33 +124,10 @@ export const CreateFirewallDrawer = React.memo( mutateAsync(payload) .then((response) => { setSubmitting(false); - queryClient.invalidateQueries([firewallQueryKey]); enqueueSnackbar(`Firewall ${payload.label} successfully created`, { variant: 'success', }); - // Invalidate for Linodes - if (payload.devices?.linodes) { - payload.devices.linodes.forEach((linodeId) => { - queryClient.invalidateQueries([ - linodesQueryKey, - 'linode', - linodeId, - 'firewalls', - ]); - }); - } - - // Invalidate for NodeBalancers - if (payload.devices?.nodebalancers) { - for (const id of payload.devices.nodebalancers) { - queryClient.invalidateQueries({ - queryKey: nodebalancerQueries.nodebalancer(id)._ctx.firewalls - .queryKey, - }); - } - } - if (onFirewallCreated) { onFirewallCreated(response); } diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx index 70d06c9a4d3..fc36b3089ec 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx @@ -5,46 +5,37 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { useDeleteFirewall, useMutateFirewall } from 'src/queries/firewalls'; -import { queryKey as firewallQueryKey } from 'src/queries/firewalls'; -import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; import { nodebalancerQueries } from 'src/queries/nodebalancers'; import { capitalize } from 'src/utilities/capitalize'; +import type { Firewall } from '@linode/api-v4'; + export type Mode = 'delete' | 'disable' | 'enable'; interface Props { mode: Mode; onClose: () => void; open: boolean; - selectedFirewallId: number; - selectedFirewallLabel: string; + selectedFirewall: Firewall; } export const FirewallDialog = React.memo((props: Props) => { const { enqueueSnackbar } = useSnackbar(); const queryClient = useQueryClient(); - const { - mode, - onClose, - open, - selectedFirewallId, - selectedFirewallLabel: label, - } = props; - - const { data: devices } = useAllFirewallDevicesQuery(selectedFirewallId); + const { mode, onClose, open, selectedFirewall } = props; const { error: updateError, isLoading: isUpdating, mutateAsync: updateFirewall, - } = useMutateFirewall(selectedFirewallId); + } = useMutateFirewall(selectedFirewall.id); const { error: deleteError, isLoading: isDeleting, mutateAsync: deleteFirewall, - } = useDeleteFirewall(selectedFirewallId); + } = useDeleteFirewall(selectedFirewall.id); const requestMap = { delete: () => deleteFirewall(), @@ -66,31 +57,30 @@ export const FirewallDialog = React.memo((props: Props) => { const onSubmit = async () => { await requestMap[mode](); + // Invalidate Firewalls assigned to NodeBalancers and Linodes when Firewall is enabled, disabled, or deleted. - // eslint-disable-next-line no-unused-expressions - devices?.forEach((device) => { - const deviceType = device.entity.type; - if (deviceType === 'linode') { - queryClient.invalidateQueries([ - linodesQueryKey, - deviceType, - device.entity.id, - 'firewalls', - ]); + for (const entity of selectedFirewall.entities) { + if (entity.type === 'nodebalancer') { + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(entity.id)._ctx.firewalls + .queryKey, + }); } - if (deviceType === 'nodebalancer') { + + if (entity.type === 'linode') { queryClient.invalidateQueries({ - queryKey: nodebalancerQueries.nodebalancer(device.entity.id)._ctx - .firewalls.queryKey, + queryKey: [linodesQueryKey, 'linode', entity.id, 'firewalls'], }); } - }); - if (mode === 'delete') { - queryClient.invalidateQueries([firewallQueryKey]); } - enqueueSnackbar(`Firewall ${label} successfully ${mode}d`, { - variant: 'success', - }); + + enqueueSnackbar( + `Firewall ${selectedFirewall.label} successfully ${mode}d`, + { + variant: 'success', + } + ); + onClose(); }; @@ -109,7 +99,7 @@ export const FirewallDialog = React.memo((props: Props) => { error={errorMap[mode]?.[0].reason} onClose={onClose} open={open} - title={`${capitalize(mode)} Firewall ${label}?`} + title={`${capitalize(mode)} Firewall ${selectedFirewall.label}?`} > Are you sure you want to {mode} this firewall? diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx index 7e37d83c7b8..6665926e079 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx @@ -18,11 +18,13 @@ import { useFirewallsQuery } from 'src/queries/firewalls'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { CreateFirewallDrawer } from './CreateFirewallDrawer'; -import { ActionHandlers as FirewallHandlers } from './FirewallActionMenu'; -import { FirewallDialog, Mode } from './FirewallDialog'; +import { FirewallDialog } from './FirewallDialog'; import { FirewallLandingEmptyState } from './FirewallLandingEmptyState'; import { FirewallRow } from './FirewallRow'; +import type { ActionHandlers as FirewallHandlers } from './FirewallActionMenu'; +import type { Mode } from './FirewallDialog'; + const preferenceKey = 'firewalls'; const FirewallLanding = () => { @@ -175,13 +177,12 @@ const FirewallLanding = () => { onClose={onCloseCreateDrawer} open={isCreateFirewallDrawerOpen} /> - {selectedFirewallId && ( + {selectedFirewall && ( setIsModalOpen(false)} open={isModalOpen} - selectedFirewallId={selectedFirewallId} - selectedFirewallLabel={selectedFirewall?.label ?? ''} + selectedFirewall={selectedFirewall} /> )} diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx index d74809ac0ab..7128c1ecb87 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx @@ -68,21 +68,21 @@ describe('FirewallRow', () => { describe('getDeviceLinks', () => { it('should return a single Link if one Device is attached', () => { const device = firewallDeviceFactory.build(); - const links = getDeviceLinks([device]); + const links = getDeviceLinks([device.entity]); const { getByText } = renderWithTheme(links); expect(getByText(device.entity.label)); }); it('should render up to three comma-separated links', () => { const devices = firewallDeviceFactory.buildList(3); - const links = getDeviceLinks(devices); + const links = getDeviceLinks(devices.map((device) => device.entity)); const { queryAllByTestId } = renderWithTheme(links); expect(queryAllByTestId('firewall-row-link')).toHaveLength(3); }); it('should render "plus N more" text for any devices over three', () => { const devices = firewallDeviceFactory.buildList(13); - const links = getDeviceLinks(devices); + const links = getDeviceLinks(devices.map((device) => device.entity)); const { getByText, queryAllByTestId } = renderWithTheme(links); expect(queryAllByTestId('firewall-row-link')).toHaveLength(3); expect(getByText(/10 more/)); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx index 270442226fa..dcd3504860d 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx @@ -1,5 +1,3 @@ -import { Firewall, FirewallDevice } from '@linode/api-v4/lib/firewalls'; -import { APIError } from '@linode/api-v4/lib/types'; import React from 'react'; import { Link } from 'react-router-dom'; @@ -7,17 +5,17 @@ import { Hidden } from 'src/components/Hidden'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; import { capitalize } from 'src/utilities/capitalize'; -import { ActionHandlers, FirewallActionMenu } from './FirewallActionMenu'; +import { FirewallActionMenu } from './FirewallActionMenu'; + +import type { ActionHandlers } from './FirewallActionMenu'; +import type { Firewall, FirewallDeviceEntity } from '@linode/api-v4'; export interface FirewallRowProps extends Firewall, ActionHandlers {} export const FirewallRow = React.memo((props: FirewallRowProps) => { - const { id, label, rules, status, ...actionHandlers } = props; - - const { data: devices, error, isLoading } = useAllFirewallDevicesQuery(id); + const { entities, id, label, rules, status, ...actionHandlers } = props; const count = getCountOfRules(rules); @@ -34,9 +32,7 @@ export const FirewallRow = React.memo((props: FirewallRowProps) => { {getRuleString(count)} - - {getDevicesCellString(devices ?? [], isLoading, error ?? undefined)} - + {getDevicesCellString(entities)} { return [(rules.inbound || []).length, (rules.outbound || []).length]; }; -const getDevicesCellString = ( - data: FirewallDevice[], - loading: boolean, - error?: APIError[] -): JSX.Element | string => { - if (loading) { - return 'Loading...'; - } - - if (error) { - return 'Error retrieving Linodes'; - } - - if (data.length === 0) { +const getDevicesCellString = (entities: FirewallDeviceEntity[]) => { + if (entities.length === 0) { return 'None assigned'; } - return getDeviceLinks(data); + return getDeviceLinks(entities); }; -export const getDeviceLinks = (data: FirewallDevice[]): JSX.Element => { - const firstThree = data.slice(0, 3); +export const getDeviceLinks = (entities: FirewallDeviceEntity[]) => { + const firstThree = entities.slice(0, 3); return ( <> - {firstThree.map((thisDevice, idx) => ( - <> + {firstThree.map((entity, idx) => ( + {idx > 0 && ', '} - {thisDevice.entity.label} + {entity.label} - + ))} - {data.length > 3 && , plus {data.length - 3} more.} + {entities.length > 3 && , plus {entities.length - 3} more.} ); }; diff --git a/packages/manager/src/queries/firewalls.ts b/packages/manager/src/queries/firewalls.ts index a328021b773..e1b2b92eea8 100644 --- a/packages/manager/src/queries/firewalls.ts +++ b/packages/manager/src/queries/firewalls.ts @@ -1,9 +1,4 @@ import { - CreateFirewallPayload, - Firewall, - FirewallDevice, - FirewallDevicePayload, - FirewallRules, addFirewallDevice, createFirewall, deleteFirewall, @@ -14,48 +9,187 @@ import { updateFirewall, updateFirewallRules, } from '@linode/api-v4/lib/firewalls'; -import { - APIError, - Filter, - Params, - ResourcePage, -} from '@linode/api-v4/lib/types'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { EventHandlerData } from 'src/hooks/useEventHandlers'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; import { getAll } from 'src/utilities/getAll'; -import { updateInPaginatedStore } from './base'; +import { nodebalancerQueries } from './nodebalancers'; import { profileQueries } from './profile/profile'; -export const queryKey = 'firewall'; +import type { + APIError, + CreateFirewallPayload, + Filter, + Firewall, + FirewallDevice, + FirewallDevicePayload, + FirewallRules, + Params, + ResourcePage, +} from '@linode/api-v4'; +import type { EventHandlerData } from 'src/hooks/useEventHandlers'; + +const getAllFirewallDevices = ( + id: number, + passedParams: Params = {}, + passedFilter: Filter = {} +) => + getAll((params, filter) => + getFirewallDevices( + id, + { ...params, ...passedParams }, + { ...filter, ...passedFilter } + ) + )().then((data) => data.data); + +const getAllFirewallsRequest = () => + getAll((passedParams, passedFilter) => + getFirewalls(passedParams, passedFilter) + )().then((data) => data.data); + +export const firewallQueries = createQueryKeys('firewalls', { + firewall: (id: number) => ({ + contextQueries: { + devices: { + queryFn: () => getAllFirewallDevices(id), + queryKey: null, + }, + }, + queryFn: () => getFirewall(id), + queryKey: [id], + }), + firewalls: { + contextQueries: { + all: { + queryFn: getAllFirewallsRequest, + queryKey: null, + }, + paginated: (params: Params = {}, filter: Filter = {}) => ({ + queryFn: () => getFirewalls(params, filter), + queryKey: [params, filter], + }), + }, + queryKey: null, + }, +}); export const useAllFirewallDevicesQuery = (id: number) => useQuery( - [queryKey, 'firewall', id, 'devices'], - () => getAllFirewallDevices(id) + firewallQueries.firewall(id)._ctx.devices ); export const useAddFirewallDeviceMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation( - (data) => addFirewallDevice(id, data), - { - onSuccess(data) { - // Refresh the cached device list - queryClient.invalidateQueries([queryKey, 'firewall', id, 'devices']); - - // Refresh the cached result of the linode-specific firewalls query - queryClient.invalidateQueries([ - linodesQueryKey, - 'linode', - data.entity.id, - 'firewalls', - ]); - }, - } - ); + return useMutation({ + mutationFn: (data) => addFirewallDevice(id, data), + onSuccess(firewallDevice) { + // Append the new entity to the Firewall object in the paginated store + queryClient.setQueriesData>( + firewallQueries.firewalls._ctx.paginated._def, + (page) => { + if (!page) { + return undefined; + } + + const indexOfFirewall = page.data.findIndex( + (firewall) => firewall.id === id + ); + + // If the firewall does not exist on this page, don't change anything + if (indexOfFirewall === -1) { + return page; + } + + const firewall = page.data[indexOfFirewall]; + + const newData = [...page.data]; + + newData[indexOfFirewall] = { + ...firewall, + entities: [...firewall.entities, firewallDevice.entity], + }; + return { ...page, data: newData }; + } + ); + + // Append the new entity to the Firewall object in the "all firewalls" store + queryClient.setQueryData( + firewallQueries.firewalls._ctx.all.queryKey, + (firewalls) => { + if (!firewalls) { + return undefined; + } + + const indexOfFirewall = firewalls.findIndex( + (firewall) => firewall.id === id + ); + + // If the firewall does not exist in the list, don't do anything + if (indexOfFirewall === -1) { + return firewalls; + } + + const newFirewalls = [...firewalls]; + + const firewall = firewalls[indexOfFirewall]; + + newFirewalls[indexOfFirewall] = { + ...firewall, + entities: [...firewall.entities, firewallDevice.entity], + }; + + return newFirewalls; + } + ); + + // Append the new entity to the Firewall object + queryClient.setQueryData( + firewallQueries.firewall(id).queryKey, + (oldFirewall) => { + if (!oldFirewall) { + return undefined; + } + return { + ...oldFirewall, + entities: [...oldFirewall.entities, firewallDevice.entity], + }; + } + ); + + // Add device to the dedicated devices store + queryClient.setQueryData( + firewallQueries.firewall(id)._ctx.devices.queryKey, + (existingFirewallDevices) => { + if (!existingFirewallDevices) { + return [firewallDevice]; + } + return [...existingFirewallDevices, firewallDevice]; + } + ); + + // Refresh the cached result of the linode-specific firewalls query + if (firewallDevice.entity.type === 'linode') { + queryClient.invalidateQueries({ + queryKey: [ + linodesQueryKey, + 'linode', + firewallDevice.entity.id, + 'firewalls', + ], + }); + } + + // Refresh the cached result of the nodebalancer-specific firewalls query + if (firewallDevice.entity.type === 'nodebalancer') { + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(firewallDevice.entity.id) + ._ctx.firewalls.queryKey, + }); + } + }, + }); }; export const useRemoveFirewallDeviceMutation = ( @@ -64,131 +198,281 @@ export const useRemoveFirewallDeviceMutation = ( ) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>( - () => deleteFirewallDevice(firewallId, deviceId), - { - onSuccess() { - queryClient.setQueryData( - [queryKey, 'firewall', firewallId, 'devices'], - (oldData) => { - return oldData?.filter((device) => device.id !== deviceId) ?? []; - } - ); - }, - } - ); + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteFirewallDevice(firewallId, deviceId), + onSuccess() { + // Invalidate firewall lists because GET /v4/firewalls returns all entities for each firewall + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + + // Invalidate the firewall because the firewall objects has all entities and we want them to be in sync + queryClient.invalidateQueries({ + exact: true, + queryKey: firewallQueries.firewall(firewallId).queryKey, + }); + + // Remove device from the firewall's dedicaed devices store + queryClient.setQueryData( + firewallQueries.firewall(firewallId)._ctx.devices.queryKey, + (oldData) => { + return oldData?.filter((device) => device.id !== deviceId) ?? []; + } + ); + }, + }); }; export const useFirewallsQuery = (params?: Params, filter?: Filter) => { - return useQuery, APIError[]>( - [queryKey, 'paginated', params, filter], - () => getFirewalls(params, filter), - { keepPreviousData: true } - ); + return useQuery, APIError[]>({ + ...firewallQueries.firewalls._ctx.paginated(params, filter), + keepPreviousData: true, + }); }; -export const useFirewallQuery = (id: number) => { - return useQuery([queryKey, 'firewall', id], () => - getFirewall(id) - ); -}; +export const useFirewallQuery = (id: number) => + useQuery(firewallQueries.firewall(id)); export const useAllFirewallsQuery = (enabled: boolean = true) => { - return useQuery( - [queryKey, 'all'], - getAllFirewallsRequest, - { enabled } - ); + return useQuery({ + ...firewallQueries.firewalls._ctx.all, + enabled, + }); }; export const useMutateFirewall = (id: number) => { const queryClient = useQueryClient(); - return useMutation>( - (data) => updateFirewall(id, data), - { - onSuccess(firewall) { - queryClient.setQueryData([queryKey, 'firewall', id], firewall); - queryClient.invalidateQueries([queryKey, 'paginated']); - }, - } - ); + return useMutation>({ + mutationFn: (data) => updateFirewall(id, data), + onSuccess(firewall) { + // Update the firewall in the store + queryClient.setQueryData( + firewallQueries.firewall(firewall.id).queryKey, + firewall + ); + + // Invalidate firewall lists + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + }, + }); }; export const useCreateFirewall = () => { const queryClient = useQueryClient(); - return useMutation( - (data) => createFirewall(data), - { - onSuccess(firewall) { - queryClient.invalidateQueries([queryKey, 'paginated']); - queryClient.setQueryData([queryKey, 'firewall', firewall.id], firewall); - // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries(profileQueries.grants.queryKey); - }, - } - ); + return useMutation({ + mutationFn: createFirewall, + onSuccess(firewall) { + // Invalidate firewall lists + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + + // Set the firewall in the store + queryClient.setQueryData( + firewallQueries.firewall(firewall.id).queryKey, + firewall + ); + + // If a restricted user creates an entity, we must make sure grants are up to date. + queryClient.invalidateQueries({ + queryKey: profileQueries.grants.queryKey, + }); + + // For each entity attached to the firewall upon creation, invalidate + // the entity's firewall query so that firewalls are up to date + // on the entity's details/settings page. + for (const entity of firewall.entities) { + if (entity.type === 'linode') { + queryClient.invalidateQueries({ + queryKey: [linodesQueryKey, 'linode', entity.id, 'firewalls'], + }); + } + if (entity.type === 'nodebalancer') { + queryClient.invalidateQueries({ + queryKey: nodebalancerQueries.nodebalancer(entity.id)._ctx.firewalls + .queryKey, + }); + } + } + }, + }); }; export const useDeleteFirewall = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => deleteFirewall(id), { + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteFirewall(id), onSuccess() { - queryClient.removeQueries([queryKey, 'firewall', id]); - queryClient.invalidateQueries([queryKey, 'paginated']); + // Remove firewall and its subqueries from the cache + queryClient.removeQueries({ + queryKey: firewallQueries.firewall(id).queryKey, + }); + + // Invalidate firewall lists + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); }, }); }; export const useUpdateFirewallRulesMutation = (firewallId: number) => { const queryClient = useQueryClient(); - return useMutation( - (data) => updateFirewallRules(firewallId, data), - { - onSuccess(updatedRules) { - // Update rules on specific firewall - queryClient.setQueryData( - [queryKey, 'firewall', firewallId], - (oldData) => { - if (!oldData) { - return undefined; - } - return { ...oldData, rules: updatedRules }; + return useMutation({ + mutationFn: (data) => updateFirewallRules(firewallId, data), + onSuccess(updatedRules) { + // Update rules on specific firewall + queryClient.setQueryData( + firewallQueries.firewall(firewallId).queryKey, + (oldData) => { + if (!oldData) { + return undefined; + } + return { ...oldData, rules: updatedRules }; + } + ); + + // Update the Firewall object in the paginated store + queryClient.setQueriesData>( + firewallQueries.firewalls._ctx.paginated._def, + (page) => { + if (!page) { + return undefined; + } + + const indexOfFirewall = page.data.findIndex( + (firewall) => firewall.id === firewallId + ); + + // If the firewall does not exist on this page, don't change anything + if (indexOfFirewall === -1) { + return page; + } + + const firewall = page.data[indexOfFirewall]; + + const newData = [...page.data]; + + newData[indexOfFirewall] = { + ...firewall, + rules: updatedRules, + }; + return { ...page, data: newData }; + } + ); + + // Update the the Firewall object in the "all firewalls" store + queryClient.setQueryData( + firewallQueries.firewalls._ctx.all.queryKey, + (firewalls) => { + if (!firewalls) { + return undefined; } - ); - // update our paginated store with new rules - updateInPaginatedStore( - [queryKey, 'paginated'], - firewallId, - { - id: firewallId, + + const indexOfFirewall = firewalls.findIndex( + (firewall) => firewall.id === firewallId + ); + + // If the firewall does not exist in the list, don't do anything + if (indexOfFirewall === -1) { + return firewalls; + } + + const newFirewalls = [...firewalls]; + + const firewall = firewalls[indexOfFirewall]; + + newFirewalls[indexOfFirewall] = { + ...firewall, rules: updatedRules, - }, - queryClient - ); - }, - } - ); + }; + + return newFirewalls; + } + ); + }, + }); }; -const getAllFirewallDevices = ( - id: number, - passedParams: Params = {}, - passedFilter: Filter = {} -) => - getAll((params, filter) => - getFirewallDevices( - id, - { ...params, ...passedParams }, - { ...filter, ...passedFilter } - ) - )().then((data) => data.data); +export const firewallEventsHandler = ({ + event, + queryClient, +}: EventHandlerData) => { + if (!event.entity) { + // Ignore any events that don't have an associated entity + return; + } -const getAllFirewallsRequest = () => - getAll((passedParams, passedFilter) => - getFirewalls(passedParams, passedFilter) - )().then((data) => data.data); + switch (event.action) { + case 'firewall_delete': + // Invalidate firewall lists + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + + // Remove firewall from the cache + queryClient.removeQueries({ + queryKey: firewallQueries.firewall(event.entity.id).queryKey, + }); + case 'firewall_create': + // Invalidate firewall lists + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + case 'firewall_device_add': + case 'firewall_device_remove': + // For a firewall device event, the primary entity is the fireall and + // the secondary entity is the device that is added/removed + + // If a Linode is added or removed as a firewall device, invalidate it's firewalls + if (event.secondary_entity && event.secondary_entity.type === 'linode') { + queryClient.invalidateQueries({ + queryKey: [ + 'linodes', + 'linode', + event.secondary_entity.id, + 'firewalls', + ], + }); + } + + // If a NodeBalancer is added or removed as a firewall device, invalidate it's firewalls + if ( + event.secondary_entity && + event.secondary_entity.type === 'nodebalancer' + ) { + queryClient.invalidateQueries({ + queryKey: [ + 'nodebalancers', + 'nodebalancer', + event.secondary_entity.id, + 'firewalls', + ], + }); + } + + // Invalidate the firewall + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewall(event.entity.id).queryKey, + }); -export const firewallEventsHandler = ({ queryClient }: EventHandlerData) => { - // We will over-fetch a little bit, bit this ensures Cloud firewalls are *always* up to date - queryClient.invalidateQueries([queryKey]); + // Invalidate firewall lists + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + case 'firewall_disable': + case 'firewall_enable': + case 'firewall_rules_update': + case 'firewall_update': + // invalidate the firewall + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewall(event.entity.id).queryKey, + }); + // Invalidate firewall lists + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + } }; diff --git a/packages/manager/src/queries/linodes/events.ts b/packages/manager/src/queries/linodes/events.ts index 0e9aaafcca0..01c19913bf3 100644 --- a/packages/manager/src/queries/linodes/events.ts +++ b/packages/manager/src/queries/linodes/events.ts @@ -1,10 +1,10 @@ -import { EventHandlerData } from 'src/hooks/useEventHandlers'; -import { queryKey as firewallsQueryKey } from 'src/queries/firewalls'; import { accountQueries } from '../account/queries'; +import { firewallQueries } from '../firewalls'; +import { volumeQueries } from '../volumes/volumes'; import { queryKey } from './linodes'; import type { Event } from '@linode/api-v4'; -import { volumeQueries } from '../volumes/volumes'; +import type { EventHandlerData } from 'src/hooks/useEventHandlers'; /** * Event handler for Linode events @@ -94,7 +94,7 @@ export const linodeEventsHandler = ({ queryClient.invalidateQueries([queryKey, 'infinite']); // A Linode made have been on a Firewall's device list, but now that it is deleted, // it will no longer be listed as a device on that firewall. Here, we invalidate outdated firewall data. - queryClient.invalidateQueries([firewallsQueryKey]); + queryClient.invalidateQueries({ queryKey: firewallQueries._def }); // A Linode may have been attached to a Volume, but deleted. We need to refetch volumes data so that // the Volumes table does not show a Volume attached to a non-existant Linode. queryClient.invalidateQueries(volumeQueries.lists.queryKey); diff --git a/packages/manager/src/queries/linodes/firewalls.ts b/packages/manager/src/queries/linodes/firewalls.ts index 47e16d087a4..1e0f60a86cc 100644 --- a/packages/manager/src/queries/linodes/firewalls.ts +++ b/packages/manager/src/queries/linodes/firewalls.ts @@ -1,17 +1,12 @@ -import { - APIError, - Firewall, - ResourcePage, - getLinodeFirewalls, -} from '@linode/api-v4'; +import { getLinodeFirewalls } from '@linode/api-v4'; import { useQuery } from '@tanstack/react-query'; -import { queryPresets } from '../base'; import { queryKey } from './linodes'; +import type { APIError, Firewall, ResourcePage } from '@linode/api-v4'; + export const useLinodeFirewallsQuery = (linodeID: number) => useQuery, APIError[]>( [queryKey, 'linode', linodeID, 'firewalls'], - () => getLinodeFirewalls(linodeID), - queryPresets.oneTimeFetch + () => getLinodeFirewalls(linodeID) ); diff --git a/packages/manager/src/queries/nodebalancers.ts b/packages/manager/src/queries/nodebalancers.ts index e6270adceef..b48c977d1c5 100644 --- a/packages/manager/src/queries/nodebalancers.ts +++ b/packages/manager/src/queries/nodebalancers.ts @@ -20,10 +20,10 @@ import { useQueryClient, } from '@tanstack/react-query'; -import { queryKey as firewallsQueryKey } from 'src/queries/firewalls'; import { getAll } from 'src/utilities/getAll'; import { queryPresets } from './base'; +import { firewallQueries } from './firewalls'; import { profileQueries } from './profile/profile'; import type { @@ -158,7 +158,7 @@ export const useNodebalancerCreateMutation = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: createNodeBalancer, - onSuccess(nodebalancer) { + onSuccess(nodebalancer, variables) { // Invalidate paginated stores queryClient.invalidateQueries({ queryKey: nodebalancerQueries.nodebalancers.queryKey, @@ -169,7 +169,23 @@ export const useNodebalancerCreateMutation = () => { nodebalancer ); // If a restricted user creates an entity, we must make sure grants are up to date. - queryClient.invalidateQueries(profileQueries.grants.queryKey); + queryClient.invalidateQueries({ + queryKey: profileQueries.grants.queryKey, + }); + + // If a NodeBalancer is assigned to a firewall upon creation, make sure we invalidate that firewall + // so it reflects the new entity. + if (variables.firewall_id) { + // Invalidate the paginated list of firewalls because GET /v4/networking/firewalls returns all firewall entities + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewalls.queryKey, + }); + + // Invalidate the affected firewall + queryClient.invalidateQueries({ + queryKey: firewallQueries.firewall(variables.firewall_id).queryKey, + }); + } }, }); }; @@ -320,12 +336,5 @@ export const nodebalancerEventHandler = ({ queryClient.invalidateQueries({ queryKey: nodebalancerQueries.nodebalancers.queryKey, }); - - if (event.action === 'nodebalancer_delete') { - // A deleted NodeBalancer may have been associated with a Firewall, - // so we want to invalidate Firewalls to reflect the device being removed. - // @tood: Optimize this heavy invalidation - queryClient.invalidateQueries({ queryKey: [firewallsQueryKey] }); - } } }; From da1d72fb0edde9f128c8e93a1cdb38dc76e1fa50 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 13 Jun 2024 16:34:44 -0400 Subject: [PATCH 086/163] upcoming: [M3-8081] - Linode Create v2 - Handle side-effects when changing the Region (#10564) * save changes * save progress * add warnings and distributed compute features * Added changeset: Linode Create v2 - Handle side-effects when changing the Region * add comment * remove `disk_encryption` from the payload if it should be disabled --------- Co-authored-by: Banks Nussman --- ...r-10564-upcoming-features-1718118654107.md | 5 + .../components/Autocomplete/Autocomplete.tsx | 2 +- .../manager/src/components/VLANSelect.tsx | 10 +- .../Linodes/LinodeCreatev2/Region.test.tsx | 176 ++++++++++++++++++ .../Linodes/LinodeCreatev2/Region.tsx | 156 ++++++++++++++-- .../{Access.test.tsx => Security.test.tsx} | 18 +- .../{Access.tsx => Security.tsx} | 6 +- .../features/Linodes/LinodeCreatev2/index.tsx | 4 +- .../Linodes/LinodeCreatev2/utilities.ts | 58 +++--- 9 files changed, 368 insertions(+), 67 deletions(-) create mode 100644 packages/manager/.changeset/pr-10564-upcoming-features-1718118654107.md create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Region.test.tsx rename packages/manager/src/features/Linodes/LinodeCreatev2/{Access.test.tsx => Security.test.tsx} (94%) rename packages/manager/src/features/Linodes/LinodeCreatev2/{Access.tsx => Security.tsx} (95%) diff --git a/packages/manager/.changeset/pr-10564-upcoming-features-1718118654107.md b/packages/manager/.changeset/pr-10564-upcoming-features-1718118654107.md new file mode 100644 index 00000000000..b8d3556c204 --- /dev/null +++ b/packages/manager/.changeset/pr-10564-upcoming-features-1718118654107.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Linode Create v2 - Handle side-effects when changing the Region ([#10564](https://github.com/linode/manager/pull/10564)) diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.tsx index 90a7202f4d5..65f21b124e9 100644 --- a/packages/manager/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/manager/src/components/Autocomplete/Autocomplete.tsx @@ -66,7 +66,7 @@ export const Autocomplete = < props: EnhancedAutocompleteProps ) => { const { - clearOnBlur = false, + clearOnBlur, defaultValue, disablePortal = true, errorText = '', diff --git a/packages/manager/src/components/VLANSelect.tsx b/packages/manager/src/components/VLANSelect.tsx index 99d5fe82996..f95f584a680 100644 --- a/packages/manager/src/components/VLANSelect.tsx +++ b/packages/manager/src/components/VLANSelect.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useVLANsInfiniteQuery } from 'src/queries/vlans'; @@ -51,6 +51,14 @@ export const VLANSelect = (props: Props) => { const [open, setOpen] = React.useState(false); const [inputValue, setInputValue] = useState(''); + useEffect(() => { + if (!value && inputValue) { + // If the value gets cleared, make sure the TextField's value also gets cleared. + setInputValue(''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + const apiFilter = getVLANSelectFilter({ defaultFilter: filter, inputValue, diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.test.tsx new file mode 100644 index 00000000000..2e7e9b08a4c --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.test.tsx @@ -0,0 +1,176 @@ +import { waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import React from 'react'; + +import { + grantsFactory, + linodeFactory, + linodeTypeFactory, + profileFactory, + regionFactory, +} from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { Region } from './Region'; + +import type { LinodeCreateFormValues } from './utilities'; + +describe('Region', () => { + it('should render a heading', () => { + const { getAllByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + const heading = getAllByText('Region')[0]; + + expect(heading).toBeVisible(); + expect(heading.tagName).toBe('H2'); + }); + + it('should render a Region Select', () => { + const { getByPlaceholderText } = renderWithThemeAndHookFormContext({ + component: , + }); + + const select = getByPlaceholderText('Select a Region'); + + expect(select).toBeVisible(); + expect(select).toBeEnabled(); + }); + + it('should disable the region select is the user does not have permission to create Linodes', async () => { + const profile = profileFactory.build({ restricted: true }); + const grants = grantsFactory.build({ global: { add_linodes: false } }); + + server.use( + http.get('*/v4/profile/grants', () => { + return HttpResponse.json(grants); + }), + http.get('*/v4/profile', () => { + return HttpResponse.json(profile); + }) + ); + + const { getByPlaceholderText } = renderWithThemeAndHookFormContext({ + component: , + }); + + const select = getByPlaceholderText('Select a Region'); + + await waitFor(() => { + expect(select).toBeDisabled(); + }); + }); + + it('should render regions returned by the API', async () => { + const regions = regionFactory.buildList(5, { capabilities: ['Linodes'] }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage(regions)); + }) + ); + + const { + findByText, + getByPlaceholderText, + } = renderWithThemeAndHookFormContext({ + component: , + }); + + const select = getByPlaceholderText('Select a Region'); + + await userEvent.click(select); + + for (const region of regions) { + // eslint-disable-next-line no-await-in-loop + expect(await findByText(`${region.label} (${region.id})`)).toBeVisible(); + } + }); + + it('renders a warning if the user selects a region with different pricing when cloning', async () => { + const regionA = regionFactory.build({ capabilities: ['Linodes'] }); + const regionB = regionFactory.build({ capabilities: ['Linodes'] }); + + const type = linodeTypeFactory.build({ + region_prices: [{ hourly: 99, id: regionB.id, monthly: 999 }], + }); + + const linode = linodeFactory.build({ region: regionA.id, type: type.id }); + + server.use( + http.get('*/v4/linode/types/:id', () => { + return HttpResponse.json(type); + }), + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([regionA, regionB])); + }) + ); + + const { + findByText, + getByPlaceholderText, + } = renderWithThemeAndHookFormContext({ + component: , + options: { + MemoryRouter: { initialEntries: ['/linodes/create?type=Clone+Linode'] }, + }, + useFormOptions: { + defaultValues: { + linode, + }, + }, + }); + + const select = getByPlaceholderText('Select a Region'); + + await userEvent.click(select); + + await userEvent.click(await findByText(`${regionB.label} (${regionB.id})`)); + + await findByText('The selected region has a different price structure.'); + }); + + it('renders a warning if the user tries to clone across datacenters', async () => { + const regionA = regionFactory.build({ capabilities: ['Linodes'] }); + const regionB = regionFactory.build({ capabilities: ['Linodes'] }); + + const linode = linodeFactory.build({ region: regionA.id }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([regionA, regionB])); + }) + ); + + const { + findByText, + getByPlaceholderText, + getByText, + } = renderWithThemeAndHookFormContext({ + component: , + options: { + MemoryRouter: { initialEntries: ['/linodes/create?type=Clone+Linode'] }, + }, + useFormOptions: { + defaultValues: { + linode, + }, + }, + }); + + const select = getByPlaceholderText('Select a Region'); + + await userEvent.click(select); + + await userEvent.click(await findByText(`${regionB.label} (${regionB.id})`)); + + expect( + getByText( + 'Cloning a powered off instance across data centers may cause long periods of down time.' + ) + ).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx index 0497e4b5f4f..3b3d42e5896 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx @@ -1,32 +1,156 @@ import React from 'react'; -import { useController } from 'react-hook-form'; +import { useController, useFormContext, useWatch } from 'react-hook-form'; -import { SelectRegionPanel } from 'src/components/SelectRegionPanel/SelectRegionPanel'; +import { Box } from 'src/components/Box'; +import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; +import { DocsLink } from 'src/components/DocsLink/DocsLink'; +import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { isDistributedRegionSupported } from 'src/components/RegionSelect/RegionSelect.utils'; +import { RegionHelperText } from 'src/components/SelectRegionPanel/RegionHelperText'; +import { Typography } from 'src/components/Typography'; +import { useFlags } from 'src/hooks/useFlags'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +import { useTypeQuery } from 'src/queries/types'; +import { + DIFFERENT_PRICE_STRUCTURE_WARNING, + DOCS_LINK_LABEL_DC_PRICING, +} from 'src/utilities/pricing/constants'; +import { isLinodeTypeDifferentPriceInSelectedRegion } from 'src/utilities/pricing/linodes'; -import type { CreateLinodeRequest } from '@linode/api-v4'; +import { CROSS_DATA_CENTER_CLONE_WARNING } from '../LinodesCreate/constants'; +import { defaultInterfaces, useLinodeCreateQueryParams } from './utilities'; + +import type { LinodeCreateFormValues } from './utilities'; +import type { Region as RegionType } from '@linode/api-v4'; export const Region = () => { - const { field, formState } = useController({ + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); + + const flags = useFlags(); + + const { params } = useLinodeCreateQueryParams(); + + const { control, reset } = useFormContext(); + const { field, fieldState } = useController({ + control, name: 'region', }); + const selectedLinode = useWatch({ control, name: 'linode' }); + + const { data: type } = useTypeQuery( + selectedLinode?.type ?? '', + Boolean(selectedLinode) + ); + const isLinodeCreateRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_linodes', }); + const { data: regions } = useRegionsQuery(); + + const onChange = (region: RegionType) => { + const isDistributedRegion = + region.site_type === 'distributed' || region.site_type === 'edge'; + + const defaultDiskEncryptionValue = region.capabilities.includes( + 'Disk Encryption' + ) + ? 'enabled' + : undefined; + + reset((prev) => ({ + ...prev, + // Reset interfaces because VPC and VLANs are region-sepecific + interfaces: defaultInterfaces, + // Reset Cloud-init metadata because not all regions support it + metadata: undefined, + // Reset the placement group because they are region-specific + placement_group: undefined, + // Set the region + region: region.id, + // Backups and Private IP are not supported in distributed compute regions + ...(isDistributedRegion && { + backups_enabled: false, + private_ip: false, + }), + // If disk encryption is enabled, set the default value to "enabled" if the region supports it + ...(isDiskEncryptionFeatureEnabled && { + disk_encryption: defaultDiskEncryptionValue, + }), + })); + }; + + const showCrossDataCenterCloneWarning = + params.type === 'Clone Linode' && + selectedLinode && + selectedLinode.region !== field.value; + + const showClonePriceWarning = + params.type === 'Clone Linode' && + isLinodeTypeDifferentPriceInSelectedRegion({ + regionA: selectedLinode?.region, + regionB: field.value, + type, + }); + + const hideDistributedRegions = + !flags.gecko2?.enabled || + flags.gecko2?.ga || + !isDistributedRegionSupported(params.type ?? 'Distributions'); + + const showDistributedRegionIconHelperText = + !hideDistributedRegions && + regions?.some( + (region) => + region.site_type === 'distributed' || region.site_type === 'edge' + ); + return ( - + + + Region + + + + {showCrossDataCenterCloneWarning && ( + + theme.font.bold}> + {CROSS_DATA_CENTER_CLONE_WARNING} + + + )} + onChange(region)} + regionFilter={hideDistributedRegions ? 'core' : undefined} + regions={regions ?? []} + textFieldProps={{ onBlur: field.onBlur }} + value={field.value} + /> + {showClonePriceWarning && ( + + theme.font.bold}> + {DIFFERENT_PRICE_STRUCTURE_WARNING}{' '} + Learn more. + + + )} + ); }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Access.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx similarity index 94% rename from packages/manager/src/features/Linodes/LinodeCreatev2/Access.test.tsx rename to packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx index 6bdad7d20b1..001869bcf75 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Access.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx @@ -12,16 +12,16 @@ import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; -import { Access } from './Access'; +import { Security } from './Security'; import type { LinodeCreateFormValues } from './utilities'; -describe('Access', () => { +describe('Security', () => { it( 'should render a root password input', async () => { const { findByLabelText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const rootPasswordInput = await findByLabelText('Root Password'); @@ -34,7 +34,7 @@ describe('Access', () => { it('should render a SSH Keys heading', async () => { const { getAllByText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const heading = getAllByText('SSH Keys')[0]; @@ -45,7 +45,7 @@ describe('Access', () => { it('should render an "Add An SSH Key" button', async () => { const { getByText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const addSSHKeyButton = getByText('Add an SSH Key'); @@ -70,7 +70,7 @@ describe('Access', () => { ); const { findByLabelText } = renderWithThemeAndHookFormContext({ - component: , + component: , }); const rootPasswordInput = await findByLabelText('Root Password'); @@ -97,7 +97,7 @@ describe('Access', () => { ); const { findByText, getByRole } = renderWithThemeAndHookFormContext({ - component: , + component: , }); // Make sure the restricted user's SSH keys are loaded @@ -121,7 +121,7 @@ describe('Access', () => { ); const { findByText } = renderWithThemeAndHookFormContext({ - component: , + component: , options: { flags: { linodeDiskEncryption: true } }, }); @@ -150,7 +150,7 @@ describe('Access', () => { const { findByLabelText, } = renderWithThemeAndHookFormContext({ - component: , + component: , options: { flags: { linodeDiskEncryption: true } }, useFormOptions: { defaultValues: { region: region.id } }, }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Access.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.tsx similarity index 95% rename from packages/manager/src/features/Linodes/LinodeCreatev2/Access.tsx rename to packages/manager/src/features/Linodes/LinodeCreatev2/Security.tsx index 14862cb2573..e4059238d48 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Access.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.tsx @@ -11,6 +11,7 @@ import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption import { Divider } from 'src/components/Divider'; import { Paper } from 'src/components/Paper'; import { Skeleton } from 'src/components/Skeleton'; +import { Typography } from 'src/components/Typography'; import { inputMaxWidth } from 'src/foundations/themes/light'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -21,7 +22,7 @@ const PasswordInput = React.lazy( () => import('src/components/PasswordInput/PasswordInput') ); -export const Access = () => { +export const Security = () => { const { control } = useFormContext(); const { @@ -43,6 +44,9 @@ export const Access = () => { return ( + + Security + } > diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index 49ba44f093f..f476eaef074 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -17,7 +17,7 @@ import { } from 'src/queries/linodes/linodes'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; -import { Access } from './Access'; +import { Security } from './Security'; import { Actions } from './Actions'; import { Addons } from './Addons/Addons'; import { Details } from './Details/Details'; @@ -154,7 +154,7 @@ export const LinodeCreatev2 = () => { {params.type !== 'Backups' && }
    - {params.type !== 'Clone Linode' && } + {params.type !== 'Clone Linode' && } {params.type !== 'Clone Linode' && } diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index b1c76485e89..c0e337e3e7d 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -209,23 +209,23 @@ export const getInterfacesPayload = ( return undefined; }; -const defaultVPCInterface = { - ipam_address: '', - label: '', - purpose: 'vpc', -} as const; - -const defaultVLANInterface = { - ipam_address: '', - label: '', - purpose: 'vlan', -} as const; - -const defaultPublicInterface = { - ipam_address: '', - label: '', - purpose: 'public', -} as const; +export const defaultInterfaces: InterfacePayload[] = [ + { + ipam_address: '', + label: '', + purpose: 'vpc', + }, + { + ipam_address: '', + label: '', + purpose: 'vlan', + }, + { + ipam_address: '', + label: '', + purpose: 'public', + }, +]; /** * We extend the API's payload type so that we can hold some extra state @@ -268,11 +268,7 @@ export const defaultValues = async (): Promise => { return { backup_id: params.backupID, image: getDefaultImageId(params), - interfaces: [ - defaultVPCInterface, - defaultVLANInterface, - defaultPublicInterface, - ], + interfaces: defaultInterfaces, linode, private_ip: privateIp, region: linode ? linode.region : '', @@ -305,33 +301,21 @@ const getDefaultImageId = (params: ParsedLinodeCreateQueryParams) => { }; const defaultValuesForImages = { - interfaces: [ - defaultVPCInterface, - defaultVLANInterface, - defaultPublicInterface, - ], + interfaces: defaultInterfaces, region: '', type: '', }; const defaultValuesForDistributions = { image: DEFAULT_DISTRIBUTION, - interfaces: [ - defaultVPCInterface, - defaultVLANInterface, - defaultPublicInterface, - ], + interfaces: defaultInterfaces, region: '', type: '', }; const defaultValuesForStackScripts = { image: undefined, - interfaces: [ - defaultVPCInterface, - defaultVLANInterface, - defaultPublicInterface, - ], + interfaces: defaultInterfaces, region: '', stackscript_id: undefined, type: '', From ee2707c678d50d33a45b431f02692905668d2f22 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Thu, 13 Jun 2024 13:43:26 -0700 Subject: [PATCH 087/163] refactor: [M3-8101] - Use `network-transfer/prices` endpoint for transfer overage pricing (#10566) * Add network transfer prices endpoint * Use new endpoint in OveragePricing * Add factories, MSW endpoint, and test coverage * Clean up now that prod API endpoint is as expected * Add error and loading state to Create Bucket drawer * Add loading and error state to OMC drawer * Add changesets --- .../pr-10566-added-1718123757956.md | 5 ++ packages/api-v4/src/index.ts | 2 + packages/api-v4/src/network-transfer/index.ts | 1 + .../api-v4/src/network-transfer/prices.ts | 10 ++++ .../pr-10566-changed-1718123674587.md | 5 ++ packages/manager/src/factories/types.ts | 37 +++++++++++++ .../shared/CloudPulseResourcesSelect.test.tsx | 2 +- .../BucketLanding/CreateBucketDrawer.tsx | 19 +++++-- .../BucketLanding/OMC_CreateBucketDrawer.tsx | 29 +++++++++- .../BucketLanding/OveragePricing.test.tsx | 29 +++++++--- .../BucketLanding/OveragePricing.tsx | 54 ++++++++++++------- .../manager/src/queries/networkTransfer.ts | 23 ++++++++ .../src/utilities/pricing/constants.ts | 12 +---- .../src/utilities/pricing/dynamicPricing.ts | 11 ---- 14 files changed, 183 insertions(+), 56 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10566-added-1718123757956.md create mode 100644 packages/api-v4/src/network-transfer/index.ts create mode 100644 packages/api-v4/src/network-transfer/prices.ts create mode 100644 packages/manager/.changeset/pr-10566-changed-1718123674587.md create mode 100644 packages/manager/src/queries/networkTransfer.ts diff --git a/packages/api-v4/.changeset/pr-10566-added-1718123757956.md b/packages/api-v4/.changeset/pr-10566-added-1718123757956.md new file mode 100644 index 00000000000..33f278dce49 --- /dev/null +++ b/packages/api-v4/.changeset/pr-10566-added-1718123757956.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +New endpoint for `network-transfer/prices`` ([#10566](https://github.com/linode/manager/pull/10566)) diff --git a/packages/api-v4/src/index.ts b/packages/api-v4/src/index.ts index b62ab50d817..8de3cbfcf6f 100644 --- a/packages/api-v4/src/index.ts +++ b/packages/api-v4/src/index.ts @@ -22,6 +22,8 @@ export * from './managed'; export * from './networking'; +export * from './network-transfer'; + export * from './nodebalancers'; export * from './object-storage'; diff --git a/packages/api-v4/src/network-transfer/index.ts b/packages/api-v4/src/network-transfer/index.ts new file mode 100644 index 00000000000..19729308f7c --- /dev/null +++ b/packages/api-v4/src/network-transfer/index.ts @@ -0,0 +1 @@ +export * from './prices'; diff --git a/packages/api-v4/src/network-transfer/prices.ts b/packages/api-v4/src/network-transfer/prices.ts new file mode 100644 index 00000000000..ccb0233bb75 --- /dev/null +++ b/packages/api-v4/src/network-transfer/prices.ts @@ -0,0 +1,10 @@ +import { API_ROOT } from 'src/constants'; +import Request, { setMethod, setURL, setParams } from 'src/request'; +import { Params, PriceType, ResourcePage } from 'src/types'; + +export const getNetworkTransferPrices = (params?: Params) => + Request>( + setURL(`${API_ROOT}/network-transfer/prices`), + setMethod('GET'), + setParams(params) + ); diff --git a/packages/manager/.changeset/pr-10566-changed-1718123674587.md b/packages/manager/.changeset/pr-10566-changed-1718123674587.md new file mode 100644 index 00000000000..4d7abc2ec23 --- /dev/null +++ b/packages/manager/.changeset/pr-10566-changed-1718123674587.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Use dynamic outbound transfer pricing with `network-transfer/prices` endpoint ([#10566](https://github.com/linode/manager/pull/10566)) diff --git a/packages/manager/src/factories/types.ts b/packages/manager/src/factories/types.ts index 5229d085b50..b184dcbbbf5 100644 --- a/packages/manager/src/factories/types.ts +++ b/packages/manager/src/factories/types.ts @@ -218,3 +218,40 @@ export const objectStorageOverageTypeFactory = Factory.Sync.makeFactory( + { + id: 'distributed_network_transfer', + label: 'Distributed Network Transfer', + price: { + hourly: 0.01, + monthly: null, + }, + region_prices: [], + transfer: 0, + } +); + +export const networkTransferPriceTypeFactory = Factory.Sync.makeFactory( + { + id: 'network_transfer', + label: 'Network Transfer', + price: { + hourly: 0.005, + monthly: null, + }, + region_prices: [ + { + hourly: 0.015, + id: 'id-cgk', + monthly: null, + }, + { + hourly: 0.007, + id: 'br-gru', + monthly: null, + }, + ], + transfer: 0, + } +); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx index dc745704d43..41ece50f4f1 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.test.tsx @@ -20,7 +20,7 @@ vi.mock('src/queries/cloudpulse/resources', async () => { const mockResourceHandler = vi.fn(); const SELECT_ALL = 'Select All'; -const ARIA_SELECTED = 'aria-selected' +const ARIA_SELECTED = 'aria-selected'; describe('CloudPulseResourcesSelect component tests', () => { it('should render disabled component if the the props are undefined or regions and service type does not have any resources', () => { diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx index 11c89b01580..71fb9116ef1 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx @@ -15,6 +15,7 @@ import { useMutateAccountAgreements, } from 'src/queries/account/agreements'; import { useAccountSettings } from 'src/queries/account/settings'; +import { useNetworkTransferPricesQuery } from 'src/queries/networkTransfer'; import { useCreateBucketMutation, useObjectStorageBuckets, @@ -77,12 +78,20 @@ export const CreateBucketDrawer = (props: Props) => { }); const { - data: types, - isError: isErrorTypes, - isLoading: isLoadingTypes, + data: objTypes, + isError: isErrorObjTypes, + isInitialLoading: isLoadingObjTypes, } = useObjectStorageTypesQuery(isOpen); - - const isInvalidPrice = !types || isErrorTypes; + const { + data: transferTypes, + isError: isErrorTransferTypes, + isInitialLoading: isLoadingTransferTypes, + } = useNetworkTransferPricesQuery(isOpen); + + const isErrorTypes = isErrorTransferTypes || isErrorObjTypes; + const isLoadingTypes = isLoadingTransferTypes || isLoadingObjTypes; + const isInvalidPrice = + !objTypes || !transferTypes || isErrorTypes || isErrorTransferTypes; const { error, diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx index 1438e669c81..4340b64e5f6 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx @@ -13,9 +13,11 @@ import { useMutateAccountAgreements, } from 'src/queries/account/agreements'; import { useAccountSettings } from 'src/queries/account/settings'; +import { useNetworkTransferPricesQuery } from 'src/queries/networkTransfer'; import { useCreateBucketMutation, useObjectStorageBuckets, + useObjectStorageTypesQuery, } from 'src/queries/objectStorage'; import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -23,6 +25,7 @@ import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { sendCreateBucketEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getErrorMap } from 'src/utilities/errorUtils'; import { getGDPRDetails } from 'src/utilities/formatRegion'; +import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; import { EnableObjectStorageModal } from '../EnableObjectStorageModal'; import { BucketRegions } from './BucketRegions'; @@ -58,6 +61,22 @@ export const OMC_CreateBucketDrawer = (props: Props) => { regions: regionsSupportingObjectStorage, }); + const { + data: objTypes, + isError: isErrorObjTypes, + isInitialLoading: isLoadingObjTypes, + } = useObjectStorageTypesQuery(isOpen); + const { + data: transferTypes, + isError: isErrorTransferTypes, + isInitialLoading: isLoadingTransferTypes, + } = useNetworkTransferPricesQuery(isOpen); + + const isErrorTypes = isErrorTransferTypes || isErrorObjTypes; + const isLoadingTypes = isLoadingTransferTypes || isLoadingObjTypes; + const isInvalidPrice = + !objTypes || !transferTypes || isErrorTypes || isErrorTransferTypes; + const { error, isLoading, @@ -176,9 +195,15 @@ export const OMC_CreateBucketDrawer = (props: Props) => { 'data-testid': 'create-bucket-button', disabled: !formik.values.region || - (showGDPRCheckbox && !hasSignedAgreement), + (showGDPRCheckbox && !hasSignedAgreement) || + isErrorTypes, label: 'Create Bucket', - loading: isLoading, + loading: + isLoading || Boolean(formik.values.region && isLoadingTypes), + tooltipText: + !isLoadingTypes && isInvalidPrice + ? PRICES_RELOAD_ERROR_NOTICE_TEXT + : '', type: 'submit', }} secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx index 43330dcaa49..c7d0c2eba23 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.test.tsx @@ -2,14 +2,12 @@ import { fireEvent } from '@testing-library/react'; import React from 'react'; import { + distributedNetworkTransferPriceTypeFactory, + networkTransferPriceTypeFactory, objectStorageOverageTypeFactory, objectStorageTypeFactory, } from 'src/factories'; -import { - OBJ_STORAGE_PRICE, - UNKNOWN_PRICE, -} from 'src/utilities/pricing/constants'; -import { objectStoragePriceIncreaseMap } from 'src/utilities/pricing/dynamicPricing'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { @@ -23,7 +21,13 @@ const mockObjectStorageTypes = [ objectStorageOverageTypeFactory.build(), ]; +const mockNetworkTransferTypes = [ + distributedNetworkTransferPriceTypeFactory.build(), + networkTransferPriceTypeFactory.build(), +]; + const queryMocks = vi.hoisted(() => ({ + useNetworkTransferPricesQuery: vi.fn().mockReturnValue({}), useObjectStorageTypesQuery: vi.fn().mockReturnValue({}), })); @@ -35,11 +39,22 @@ vi.mock('src/queries/objectStorage', async () => { }; }); +vi.mock('src/queries/networkTransfer', async () => { + const actual = await vi.importActual('src/queries/networkTransfer'); + return { + ...actual, + useNetworkTransferPricesQuery: queryMocks.useNetworkTransferPricesQuery, + }; +}); + describe('OveragePricing', async () => { beforeAll(() => { queryMocks.useObjectStorageTypesQuery.mockReturnValue({ data: mockObjectStorageTypes, }); + queryMocks.useNetworkTransferPricesQuery.mockReturnValue({ + data: mockNetworkTransferTypes, + }); }); it('Renders base overage pricing for a region without price increases', () => { @@ -49,7 +64,7 @@ describe('OveragePricing', async () => { getByText(`$${mockObjectStorageTypes[1].price.hourly?.toFixed(2)} per GB`, { exact: false, }); - getByText(`$${OBJ_STORAGE_PRICE.transfer_overage} per GB`, { + getByText(`$${mockNetworkTransferTypes[1].price.hourly} per GB`, { exact: false, }); }); @@ -60,7 +75,7 @@ describe('OveragePricing', async () => { exact: false, }); getByText( - `$${objectStoragePriceIncreaseMap['br-gru'].transfer_overage} per GB`, + `$${mockNetworkTransferTypes[1].region_prices[1].hourly} per GB`, { exact: false } ); }); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx index 7a277db53ec..fa139dac2a0 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OveragePricing.tsx @@ -1,4 +1,3 @@ -import { Region } from '@linode/api-v4'; import { styled } from '@mui/material/styles'; import React from 'react'; @@ -6,15 +5,12 @@ import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress'; import { TextTooltip } from 'src/components/TextTooltip'; import { Typography } from 'src/components/Typography'; +import { useNetworkTransferPricesQuery } from 'src/queries/networkTransfer'; import { useObjectStorageTypesQuery } from 'src/queries/objectStorage'; -import { - OBJ_STORAGE_PRICE, - UNKNOWN_PRICE, -} from 'src/utilities/pricing/constants'; -import { - getDCSpecificPriceByType, - objectStoragePriceIncreaseMap, -} from 'src/utilities/pricing/dynamicPricing'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; +import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; + +import type { Region } from '@linode/api-v4'; interface Props { regionId: Region['id']; @@ -28,24 +24,44 @@ export const GLOBAL_TRANSFER_POOL_TOOLTIP_TEXT = export const OveragePricing = (props: Props) => { const { regionId } = props; - const { data: types, isError, isLoading } = useObjectStorageTypesQuery(); + const { + data: objTypes, + isError: isErrorObjTypes, + isLoading: isLoadingObjTypes, + } = useObjectStorageTypesQuery(); + const { + data: transferTypes, + isError: isErrorTransferTypes, + isLoading: isLoadingTransferTypes, + } = useNetworkTransferPricesQuery(); - const overageType = types?.find( + const storageOverageType = objTypes?.find( (type) => type.id === 'objectstorage-overage' ); + const transferOverageType = transferTypes?.find( + (type) => type.id === 'network_transfer' + ); const storageOveragePrice = getDCSpecificPriceByType({ decimalPrecision: 3, interval: 'hourly', regionId, - type: overageType, + type: storageOverageType, + }); + const transferOveragePrice = getDCSpecificPriceByType({ + decimalPrecision: 3, + interval: 'hourly', + regionId, + type: transferOverageType, }); - const isDcSpecificPricingRegion = objectStoragePriceIncreaseMap.hasOwnProperty( - regionId + const isDcSpecificPricingRegion = Boolean( + transferOverageType?.region_prices.find( + (region_price) => region_price.id === regionId + ) ); - return isLoading ? ( + return isLoadingObjTypes || isLoadingTransferTypes ? ( @@ -55,7 +71,7 @@ export const OveragePricing = (props: Props) => { For this region, additional storage costs{' '} $ - {storageOveragePrice && !isError + {storageOveragePrice && !isErrorObjTypes ? parseFloat(storageOveragePrice) : UNKNOWN_PRICE}{' '} per GB @@ -67,9 +83,9 @@ export const OveragePricing = (props: Props) => { Outbound transfer will cost{' '} $ - {isDcSpecificPricingRegion - ? objectStoragePriceIncreaseMap[regionId].transfer_overage - : OBJ_STORAGE_PRICE.transfer_overage}{' '} + {transferOveragePrice && !isErrorTransferTypes + ? parseFloat(transferOveragePrice) + : UNKNOWN_PRICE}{' '} per GB {' '} if it exceeds{' '} diff --git a/packages/manager/src/queries/networkTransfer.ts b/packages/manager/src/queries/networkTransfer.ts new file mode 100644 index 00000000000..29f9ed3b6ae --- /dev/null +++ b/packages/manager/src/queries/networkTransfer.ts @@ -0,0 +1,23 @@ +import { getNetworkTransferPrices } from '@linode/api-v4'; +import { useQuery } from '@tanstack/react-query'; + +import { getAll } from 'src/utilities/getAll'; + +import { queryPresets } from './base'; + +import type { APIError, PriceType } from '@linode/api-v4'; + +export const queryKey = 'network-transfer'; + +const getAllNetworkTransferPrices = () => + getAll((params) => getNetworkTransferPrices(params))().then( + (data) => data.data + ); + +export const useNetworkTransferPricesQuery = (enabled = true) => + useQuery({ + queryFn: getAllNetworkTransferPrices, + queryKey: [queryKey, 'prices'], + ...queryPresets.oneTimeFetch, + enabled, + }); diff --git a/packages/manager/src/utilities/pricing/constants.ts b/packages/manager/src/utilities/pricing/constants.ts index 190b1b7d824..9dbd49f171e 100644 --- a/packages/manager/src/utilities/pricing/constants.ts +++ b/packages/manager/src/utilities/pricing/constants.ts @@ -1,17 +1,7 @@ -export interface ObjStoragePriceObject { - monthly: number; - storage_overage: number; - transfer_overage: number; -} - // These values will eventually come from the API, but for now they are hardcoded and // used to generate the region based dynamic pricing. export const LKE_HA_PRICE = 60; -export const OBJ_STORAGE_PRICE: ObjStoragePriceObject = { - monthly: 5.0, - storage_overage: 0.02, - transfer_overage: 0.005, -}; + export const UNKNOWN_PRICE = '--.--'; export const PRICE_ERROR_TOOLTIP_TEXT = 'There was an error loading the price.'; export const PRICES_RELOAD_ERROR_NOTICE_TEXT = diff --git a/packages/manager/src/utilities/pricing/dynamicPricing.ts b/packages/manager/src/utilities/pricing/dynamicPricing.ts index b190b0c5d64..b6e17da0b60 100644 --- a/packages/manager/src/utilities/pricing/dynamicPricing.ts +++ b/packages/manager/src/utilities/pricing/dynamicPricing.ts @@ -53,17 +53,6 @@ export const priceIncreaseMap = { 'id-cgk': 0.2, // Jakarta }; -export const objectStoragePriceIncreaseMap = { - 'br-gru': { - storage_overage: 0.028, - transfer_overage: 0.007, - }, - 'id-cgk': { - storage_overage: 0.024, - transfer_overage: 0.015, - }, -}; - /** * This function is used to calculate the dynamic pricing for a given entity, based on potential region increased costs. * @example From c50ab9ff26208a1f28208f7ac3f1807f3ee47ac8 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-linode@users.noreply.github.com> Date: Thu, 13 Jun 2024 17:21:35 -0400 Subject: [PATCH 088/163] change: [M3-8153] - Rename to Choose an OS in Linode Create flow (#10554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Rename `Choose a Distribution` to `Choose an OS` in the Linode Create flow. The tab will stay as Distribution for now. ## How to test 🧪 ### Verification steps (How to verify changes) - Go to `/linodes/create?type=Distributions` --- .../pr-10554-changed-1718122854647.md | 5 ++ .../manager/cypress/support/ui/constants.ts | 3 +- .../support/ui/pages/linode-create-page.ts | 60 +++++++++---------- .../Tabs/Distributions.test.tsx | 2 +- .../LinodeCreatev2/Tabs/Distributions.tsx | 2 +- .../Linodes/LinodesCreate/LinodeCreate.tsx | 2 +- 6 files changed, 39 insertions(+), 35 deletions(-) create mode 100644 packages/manager/.changeset/pr-10554-changed-1718122854647.md diff --git a/packages/manager/.changeset/pr-10554-changed-1718122854647.md b/packages/manager/.changeset/pr-10554-changed-1718122854647.md new file mode 100644 index 00000000000..87be53cbdf0 --- /dev/null +++ b/packages/manager/.changeset/pr-10554-changed-1718122854647.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Rename to 'Choose a Distribution' to 'Choose an OS' in Linode Create flow ([#10554](https://github.com/linode/manager/pull/10554)) diff --git a/packages/manager/cypress/support/ui/constants.ts b/packages/manager/cypress/support/ui/constants.ts index 9e791402da7..f067604ea1d 100644 --- a/packages/manager/cypress/support/ui/constants.ts +++ b/packages/manager/cypress/support/ui/constants.ts @@ -41,8 +41,7 @@ export interface Page { // List of Routes and validator of the route export const pages: Page[] = [ { - assertIsLoaded: () => - cy.findByText('Choose a Distribution').should('be.visible'), + assertIsLoaded: () => cy.findByText('Choose an OS').should('be.visible'), goWithUI: [ { go: () => { diff --git a/packages/manager/cypress/support/ui/pages/linode-create-page.ts b/packages/manager/cypress/support/ui/pages/linode-create-page.ts index 1f24a0899ca..ddd46b2702c 100644 --- a/packages/manager/cypress/support/ui/pages/linode-create-page.ts +++ b/packages/manager/cypress/support/ui/pages/linode-create-page.ts @@ -8,33 +8,13 @@ import { ui } from 'support/ui'; * Page utilities for interacting with the Linode create page. */ export const linodeCreatePage = { - /** - * Sets the Linode's label. - * - * @param linodeLabel - Linode label to set. - */ - setLabel: (linodeLabel: string) => { - cy.findByLabelText('Linode Label').type(`{selectall}{del}${linodeLabel}`); - }, - - /** - * Sets the Linode's root password. - * - * @param linodePassword - Root password to set. - */ - setRootPassword: (linodePassword: string) => { - cy.findByLabelText('Root Password').as('rootPasswordField').click(); - - cy.get('@rootPasswordField').type(linodePassword, { log: false }); - }, - /** * Selects the Image with the given name. * * @param imageName - Name of Image to select. */ selectImage: (imageName: string) => { - cy.findByText('Choose a Distribution') + cy.findByText('Choose an OS') .closest('[data-qa-paper]') .within(() => { ui.autocomplete.find().click(); @@ -46,15 +26,6 @@ export const linodeCreatePage = { }); }, - /** - * Select the Region with the given ID. - * - * @param regionId - ID of Region to select. - */ - selectRegionById: (regionId: string) => { - ui.regionSelect.find().click().type(`${regionId}{enter}`); - }, - /** * Select the given Linode plan. * @@ -91,4 +62,33 @@ export const linodeCreatePage = { cy.get('@selectionCard').click(); }); }, + + /** + * Select the Region with the given ID. + * + * @param regionId - ID of Region to select. + */ + selectRegionById: (regionId: string) => { + ui.regionSelect.find().click().type(`${regionId}{enter}`); + }, + + /** + * Sets the Linode's label. + * + * @param linodeLabel - Linode label to set. + */ + setLabel: (linodeLabel: string) => { + cy.findByLabelText('Linode Label').type(`{selectall}{del}${linodeLabel}`); + }, + + /** + * Sets the Linode's root password. + * + * @param linodePassword - Root password to set. + */ + setRootPassword: (linodePassword: string) => { + cy.findByLabelText('Root Password').as('rootPasswordField').click(); + + cy.get('@rootPasswordField').type(linodePassword, { log: false }); + }, }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.test.tsx index 020bb95191f..6e2f6f28f14 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.test.tsx @@ -10,7 +10,7 @@ describe('Distributions', () => { component: , }); - const header = getByText('Choose a Distribution'); + const header = getByText('Choose an OS'); expect(header).toBeVisible(); expect(header.tagName).toBe('H2'); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.tsx index d76c3cdbbef..4c7aa4d24c7 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Distributions.tsx @@ -19,7 +19,7 @@ export const Distributions = () => { return ( - Choose a Distribution + Choose an OS Date: Thu, 13 Jun 2024 21:46:47 -0400 Subject: [PATCH 089/163] refactor: M3-7034 - Event Messages Part 2 (#10550) * Initial commit: trying things * save wip * save progres * save work * save work * custom message * more custom message and formatting * UI touchups * UI touchups 2 * cleanup * adding coverage * UI updates * Documentation * Added changeset: Event Messages Refactor: progress events * feedback @hkhalil-akamai & @bnussman-akamai * feedback @jaalah-akamai --- docs/development-guide/15-api-events.md | 37 +++- .../pr-10550-tech-stories-1718069370794.md | 5 + packages/manager/src/assets/icons/account.svg | 2 +- .../src/features/Events/EventRowV2.tsx | 27 ++- .../src/features/Events/EventsLanding.tsx | 7 +- .../manager/src/features/Events/constants.ts | 30 +++- .../src/features/Events/factories/linode.tsx | 61 +++++-- .../src/features/Events/utils.test.tsx | 78 +++++++- .../manager/src/features/Events/utils.tsx | 86 +++++++++ .../NotificationCenter/EventsV2.test.tsx | 34 ++++ .../features/NotificationCenter/EventsV2.tsx | 29 +++ .../NotificationData/RenderEvent.styles.ts | 29 ++- .../NotificationData/RenderEvent.tsx | 36 +--- .../NotificationData/RenderEventV2.test.tsx | 61 +++++++ .../NotificationData/RenderEventV2.tsx | 68 +++++++ .../NotificationData/RenderProgressEvent.tsx | 11 +- .../useEventNotifications.tsx | 6 +- .../useEventNotificationsV2.tsx | 48 +++++ .../NotificationSection.tsx | 10 +- .../NotificationMenu/NotificationMenu.tsx | 1 + .../NotificationMenuV2.test.tsx | 14 ++ .../NotificationMenu/NotificationMenuV2.tsx | 169 ++++++++++++++++++ .../manager/src/features/TopMenu/TopMenu.tsx | 10 +- 23 files changed, 787 insertions(+), 72 deletions(-) create mode 100644 packages/manager/.changeset/pr-10550-tech-stories-1718069370794.md create mode 100644 packages/manager/src/features/NotificationCenter/EventsV2.test.tsx create mode 100644 packages/manager/src/features/NotificationCenter/EventsV2.tsx create mode 100644 packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.test.tsx create mode 100644 packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx create mode 100644 packages/manager/src/features/NotificationCenter/NotificationData/useEventNotificationsV2.tsx create mode 100644 packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.test.tsx create mode 100644 packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.tsx diff --git a/docs/development-guide/15-api-events.md b/docs/development-guide/15-api-events.md index 8e6e2532c97..7a7f9f3cc0f 100644 --- a/docs/development-guide/15-api-events.md +++ b/docs/development-guide/15-api-events.md @@ -7,7 +7,7 @@ In order to display these messages in the application (Notification Center, /eve ## Adding a new Action and Composing Messages In order to add a new Action, one must add a new key to the read-only `EventActionKeys` constant array in the api-v4 package. -Once that's done, a related entry must be added to the `eventMessages` Event Map. In order to do so, the entry can either be added to an existing Event Factory or a new one. `eventMessages` is strictly typed, so the decision where to add the new Action will be clear. ex: +Once that's done, a related entry must be added to the `eventMessages` Event Map. In order to do so, the entry can either be added to an existing Event Factory or a new one. `eventMessages` is strictly typed, so the action needs to be added to an existing factory or a new one, depending on its key (in this example the action starts with `linode_` so it belongs in the `linode.tsx` factory): ```Typescript import { EventLink } from '../EventLink'; @@ -32,6 +32,41 @@ The convention to compose the message is as follows: - the primary action: (ex: `created`) - its correlated negation for negative actions (ex: `could not be created.`) - The `message` should be also handled via the `` in order to handle potential formatting from the API string (ticks to indicate code blocks). +- The message composition can be enhanced by using custom components. For instance, if we need to fetch extra data based on an event entity, we can simply write a new component to include in the message: + +```Typescript +export const linode: PartialEventMap<'linode'> = { + linode_migrate_datacenter: { + started: (e) => , + }, +}; + +const LinodeMigrateDataCenterMessage = ({ event }: { event: Event }) => { + const { data: linode } = useLinodeQuery(event.entity?.id ?? -1); + const { data: regions } = useRegionsQuery(); + const region = regions?.find((r) => r.id === linode?.region); + + return ( + <> + Linode is being{' '} + migrated + {region && ( + <> + {' '} + to {region.label} + + )} + . + + ); +}; +``` + +## In Progress Events + +Some event messages are meant to be displayed alongside a progress bar to show the user the percentage of the action's completion. When an action is in progress, the polling interval switches to every two seconds to provide real-time feedback. + +Despite receiving a `percent_complete` value from the API, not all actions are suitable for displaying visual progress, often because they're too short, or we only receive 0% and 100% from the endpoint. To allow only certain events to feature the progress bar, their action keys must be added to the `ACTIONS_TO_INCLUDE_AS_PROGRESS_EVENTS` constant. ## Displaying Events in snackbars diff --git a/packages/manager/.changeset/pr-10550-tech-stories-1718069370794.md b/packages/manager/.changeset/pr-10550-tech-stories-1718069370794.md new file mode 100644 index 00000000000..2c8cfaa986c --- /dev/null +++ b/packages/manager/.changeset/pr-10550-tech-stories-1718069370794.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Event Messages Refactor: progress events ([#10550](https://github.com/linode/manager/pull/10550)) diff --git a/packages/manager/src/assets/icons/account.svg b/packages/manager/src/assets/icons/account.svg index 810c046b89c..31b3352dd0b 100644 --- a/packages/manager/src/assets/icons/account.svg +++ b/packages/manager/src/assets/icons/account.svg @@ -1 +1 @@ - + diff --git a/packages/manager/src/features/Events/EventRowV2.tsx b/packages/manager/src/features/Events/EventRowV2.tsx index 7a37cd42767..3f885b7bccc 100644 --- a/packages/manager/src/features/Events/EventRowV2.tsx +++ b/packages/manager/src/features/Events/EventRowV2.tsx @@ -1,7 +1,7 @@ // TODO eventMessagesV2: rename to EventRow.tsx when flag is removed -import { Event } from '@linode/api-v4/lib/account'; import * as React from 'react'; +import { BarPercent } from 'src/components/BarPercent'; import { Box } from 'src/components/Box'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { Hidden } from 'src/components/Hidden'; @@ -9,7 +9,10 @@ import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { getEventTimestamp } from 'src/utilities/eventUtils'; -import { getEventMessage } from './utils'; +import { StyledGravatar } from './EventRow.styles'; +import { formatProgressEvent, getEventMessage } from './utils'; + +import type { Event } from '@linode/api-v4/lib/account'; interface EventRowProps { entityId?: number; @@ -29,18 +32,32 @@ export const EventRowV2 = (props: EventRowProps) => { return null; } + const { progressEventDisplay, showProgress } = formatProgressEvent(event); + return ( - {message} + {message} + {showProgress && ( + + )} - {username ?? 'Linode'} + + + {username ?? 'Linode'} + - {timestamp.toRelative()} + {progressEventDisplay} {username && (
    diff --git a/packages/manager/src/features/Events/EventsLanding.tsx b/packages/manager/src/features/Events/EventsLanding.tsx index 6cb75abaa2b..33bfba77ee5 100644 --- a/packages/manager/src/features/Events/EventsLanding.tsx +++ b/packages/manager/src/features/Events/EventsLanding.tsx @@ -10,6 +10,7 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; +import { EVENTS_LIST_FILTER } from 'src/features/Events/constants'; import { useFlags } from 'src/hooks/useFlags'; import { useEventsInfiniteQuery } from 'src/queries/events/events'; @@ -22,8 +23,6 @@ import { StyledTypography, } from './EventsLanding.styles'; -import type { Filter } from '@linode/api-v4'; - interface Props { emptyMessage?: string; // Custom message for the empty state (i.e. no events). entityId?: number; @@ -33,7 +32,7 @@ export const EventsLanding = (props: Props) => { const { emptyMessage, entityId } = props; const flags = useFlags(); - const filter: Filter = { action: { '+neq': 'profile_update' } }; + const filter = EVENTS_LIST_FILTER; if (entityId) { filter['entity.id'] = entityId; @@ -117,7 +116,7 @@ export const EventsLanding = (props: Props) => {
    )} - Relative Date + Relative Date Absolute Date diff --git a/packages/manager/src/features/Events/constants.ts b/packages/manager/src/features/Events/constants.ts index ff858a43a21..b1ed2afe60d 100644 --- a/packages/manager/src/features/Events/constants.ts +++ b/packages/manager/src/features/Events/constants.ts @@ -1,5 +1,5 @@ // TODO eventMessagesV2: delete when flag is removed -import type { Event } from '@linode/api-v4/lib/account'; +import type { Event, Filter } from '@linode/api-v4'; export const EVENT_ACTIONS: Event['action'][] = [ 'account_settings_update', @@ -129,3 +129,31 @@ export const EVENT_STATUSES: Event['status'][] = [ 'failed', 'notification', ]; + +export const ACTIONS_TO_INCLUDE_AS_PROGRESS_EVENTS: Event['action'][] = [ + 'linode_resize', + 'linode_migrate', + 'linode_migrate_datacenter', + 'disk_imagize', + 'linode_boot', + 'host_reboot', + 'lassie_reboot', + 'linode_reboot', + 'linode_shutdown', + 'linode_delete', + 'linode_clone', + 'disk_resize', + 'disk_duplicate', + 'backups_restore', + 'linode_snapshot', + 'linode_mutate', + 'linode_rebuild', + 'linode_create', + 'image_upload', + 'volume_migrate', + 'database_resize', +]; + +export const EVENTS_LIST_FILTER: Filter = { + action: { '+neq': 'profile_update' }, +}; diff --git a/packages/manager/src/features/Events/factories/linode.tsx b/packages/manager/src/features/Events/factories/linode.tsx index 7dd6a42376b..ba13193cdf1 100644 --- a/packages/manager/src/features/Events/factories/linode.tsx +++ b/packages/manager/src/features/Events/factories/linode.tsx @@ -1,10 +1,15 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; +import { useLinodeQuery } from 'src/queries/linodes/linodes'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +import { useTypeQuery } from 'src/queries/types'; +import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; import { EventLink } from '../EventLink'; import type { PartialEventMap } from '../types'; +import type { Event } from '@linode/api-v4'; export const linode: PartialEventMap<'linode'> = { linode_addip: { @@ -285,12 +290,7 @@ export const linode: PartialEventMap<'linode'> = { migrated. ), - started: (e) => ( - <> - Linode is being{' '} - migrated. - - ), + started: (e) => , }, linode_migrate_datacenter_create: { notification: (e) => ( @@ -453,11 +453,7 @@ export const linode: PartialEventMap<'linode'> = { resizing. ), - started: (e) => ( - <> - Linode is resizing. - - ), + started: (e) => , }, linode_resize_create: { notification: (e) => ( @@ -540,3 +536,46 @@ export const linode: PartialEventMap<'linode'> = { ), }, }; + +const LinodeMigrateDataCenterMessage = ({ event }: { event: Event }) => { + const { data: linode } = useLinodeQuery(event.entity?.id ?? -1); + const { data: regions } = useRegionsQuery(); + const region = regions?.find((r) => r.id === linode?.region); + + return ( + <> + Linode is being{' '} + migrated + {region && ( + <> + {' '} + to {region.label} + + )} + . + + ); +}; + +const LinodeResizeStartedMessage = ({ event }: { event: Event }) => { + const { data: linode } = useLinodeQuery(event.entity?.id ?? -1); + const type = useTypeQuery(linode?.type ?? ''); + + return ( + <> + Linode is{' '} + resizing + {type && ( + <> + {' '} + to the{' '} + {type.data && ( + {formatStorageUnits(type.data.label)} + )}{' '} + Plan + + )} + . + + ); +}; diff --git a/packages/manager/src/features/Events/utils.test.tsx b/packages/manager/src/features/Events/utils.test.tsx index d08e630e415..5367809d580 100644 --- a/packages/manager/src/features/Events/utils.test.tsx +++ b/packages/manager/src/features/Events/utils.test.tsx @@ -1,9 +1,13 @@ -import { Event } from '@linode/api-v4'; - import { eventFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { getEventMessage } from './utils'; +import { + formatEventTimeRemaining, + formatProgressEvent, + getEventMessage, +} from './utils'; + +import type { Event } from '@linode/api-v4'; describe('getEventMessage', () => { const mockEvent1: Event = eventFactory.build({ @@ -81,3 +85,71 @@ describe('getEventMessage', () => { expect(boldedWords[1]).toHaveTextContent('created'); }); }); + +describe('formatEventTimeRemaining', () => { + it('returns null if the time is null', () => { + expect(formatEventTimeRemaining(null)).toBeNull(); + }); + + it('returns null if the time is not formatted correctly', () => { + expect(formatEventTimeRemaining('12:34')).toBeNull(); + }); + + it('returns the formatted time remaining', () => { + expect(formatEventTimeRemaining('0:45:31')).toBe('46 minutes remaining'); + }); + + it('returns the formatted time remaining', () => { + expect(formatEventTimeRemaining('1:23:45')).toBe('1 hour remaining'); + }); +}); + +describe('formatProgressEvent', () => { + const mockEvent1: Event = eventFactory.build({ + action: 'linode_create', + entity: { + id: 123, + label: 'test-linode', + }, + percent_complete: null, + status: 'finished', + }); + + const mockEvent2: Event = eventFactory.build({ + action: 'linode_create', + entity: { + id: 123, + label: 'test-linode', + }, + percent_complete: 50, + status: 'started', + }); + + it('returns the correct format for a finished Event', () => { + const { progressEventDisplay, showProgress } = formatProgressEvent( + mockEvent1 + ); + + expect(progressEventDisplay).toBe('1 second ago'); + expect(showProgress).toBe(false); + }); + + it('returns the correct format for a "started" event without time remaining info', () => { + const { progressEventDisplay, showProgress } = formatProgressEvent( + mockEvent2 + ); + + expect(progressEventDisplay).toBe('Started 1 second ago'); + expect(showProgress).toBe(true); + }); + + it('returns the correct format for a "started" event with time remaining', () => { + const { progressEventDisplay, showProgress } = formatProgressEvent({ + ...mockEvent2, + + time_remaining: '0:50:00', + }); + expect(progressEventDisplay).toBe('~50 minutes remaining'); + expect(showProgress).toBe(true); + }); +}); diff --git a/packages/manager/src/features/Events/utils.tsx b/packages/manager/src/features/Events/utils.tsx index 291599ab218..a7d3f301796 100644 --- a/packages/manager/src/features/Events/utils.tsx +++ b/packages/manager/src/features/Events/utils.tsx @@ -1,3 +1,9 @@ +import { Duration } from 'luxon'; + +import { ACTIONS_TO_INCLUDE_AS_PROGRESS_EVENTS } from 'src/features/Events/constants'; +import { isInProgressEvent } from 'src/queries/events/event.helpers'; +import { getEventTimestamp } from 'src/utilities/eventUtils'; + import { eventMessages } from './factory'; import type { Event } from '@linode/api-v4'; @@ -38,3 +44,83 @@ export function getEventMessage( return message ? message(event as Event) : null; } + +/** + * Format the time remaining for an event. + * This is used for the progress events in the notification center. + */ +export const formatEventTimeRemaining = (time: null | string) => { + if (!time) { + return null; + } + + try { + const [hours, minutes, seconds] = time.split(':').map(Number); + if ( + [hours, minutes, seconds].some( + (thisNumber) => typeof thisNumber === 'undefined' + ) || + [hours, minutes, seconds].some(isNaN) + ) { + // Bad input, don't display a duration + return null; + } + const duration = Duration.fromObject({ hours, minutes, seconds }); + return hours > 0 + ? `${Math.round(duration.as('hours'))} ${ + hours > 1 ? 'hours' : 'hour' + } remaining` + : `${Math.round(duration.as('minutes'))} minutes remaining`; + } catch { + // Broken/unexpected input + return null; + } +}; + +/** + * Determines if the progress bar should be shown for an event (in the notification center or on the event page). + * + * Progress events are determined based on `event.percent_complete` being defined and < 100. + * However, some events are not worth showing progress for, usually because they complete too quickly. + * To that effect, we have an `.includes` for progress events. + * A new action should be added to `ACTIONS_TO_INCLUDE_AS_PROGRESS_EVENTS` to ensure the display of the progress bar. + * + * Additionally, we only want to show the progress bar if the event is not in a scheduled state. + * For some reason the API will return a percent_complete value for scheduled events. + */ +const shouldShowEventProgress = (event: Event): boolean => { + const isProgressEvent = isInProgressEvent(event); + + return ( + isProgressEvent && + ACTIONS_TO_INCLUDE_AS_PROGRESS_EVENTS.includes(event.action) && + event.status !== 'scheduled' + ); +}; + +interface ProgressEventDisplay { + progressEventDisplay: null | string; + showProgress: boolean; +} + +/** + * Format the event for display in the notification center and event page. + * + * If the event is a progress event, we'll show the time remaining, if available. + * Else, we'll show the time the event occurred, relative to now. + */ +export const formatProgressEvent = (event: Event): ProgressEventDisplay => { + const showProgress = shouldShowEventProgress(event); + const parsedTimeRemaining = formatEventTimeRemaining(event.time_remaining); + + const progressEventDisplay = showProgress + ? parsedTimeRemaining + ? `~${parsedTimeRemaining}` + : `Started ${getEventTimestamp(event).toRelative()}` + : getEventTimestamp(event).toRelative(); + + return { + progressEventDisplay, + showProgress, + }; +}; diff --git a/packages/manager/src/features/NotificationCenter/EventsV2.test.tsx b/packages/manager/src/features/NotificationCenter/EventsV2.test.tsx new file mode 100644 index 00000000000..5c5c198cbe0 --- /dev/null +++ b/packages/manager/src/features/NotificationCenter/EventsV2.test.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; + +import { eventFactory } from 'src/factories'; +import { renderWithTheme, resizeScreenSize } from 'src/utilities/testHelpers'; + +import { EventsV2 } from './EventsV2'; + +import type { NotificationItem } from './NotificationSection'; + +const events = eventFactory.buildList(20); +const eventNotifications: NotificationItem[] = events.map((event) => ({ + body: event.message!, + countInTotal: true, + eventId: event.id, + id: `event-${event.id}`, + showProgress: false, +})); + +describe('EventsV2', () => { + it('should render', () => { + resizeScreenSize(1600); + + const { getAllByTestId, getByText } = renderWithTheme( + + ); + + expect(getByText('Events')).toBeInTheDocument(); + expect(getByText('View all events')).toBeInTheDocument(); + expect(getAllByTestId('notification-item')).toHaveLength(20); + }); +}); diff --git a/packages/manager/src/features/NotificationCenter/EventsV2.tsx b/packages/manager/src/features/NotificationCenter/EventsV2.tsx new file mode 100644 index 00000000000..e750edf1096 --- /dev/null +++ b/packages/manager/src/features/NotificationCenter/EventsV2.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; + +import { NotificationSection } from './NotificationSection'; + +import type { NotificationItem } from './NotificationSection'; + +const NUM_EVENTS_DISPLAY = 20; + +interface EventsV2Props { + eventNotifications: NotificationItem[]; + onCloseNotificationCenter: () => void; +} + +export const EventsV2 = ({ + eventNotifications, + onCloseNotificationCenter, +}: EventsV2Props) => { + return ( + + ); +}; diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.styles.ts b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.styles.ts index 343dfce5fa9..6a8b06c3856 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.styles.ts +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.styles.ts @@ -1,10 +1,12 @@ -import { Theme } from '@mui/material/styles'; +// TODO eventMessagesV2: cleanup unused non V2 components when flag is removed import { styled } from '@mui/material/styles'; import { makeStyles } from 'tss-react/mui'; import { Box } from 'src/components/Box'; import { GravatarByUsername } from 'src/components/GravatarByUsername'; +import type { Theme } from '@mui/material/styles'; + export const RenderEventStyledBox = styled(Box, { label: 'StyledBox', })(({ theme }) => ({ @@ -12,6 +14,7 @@ export const RenderEventStyledBox = styled(Box, { backgroundColor: theme.bg.app, }, color: theme.textColors.tableHeader, + display: 'flex', gap: 16, paddingBottom: 12, paddingLeft: '20px', @@ -27,6 +30,15 @@ export const RenderEventGravatar = styled(GravatarByUsername, { minWidth: 40, })); +export const RenderEventGravatarV2 = styled(GravatarByUsername, { + label: 'StyledGravatarByUsername', +})(() => ({ + height: 32, + marginTop: 2, + minWidth: 32, + width: 32, +})); + export const useRenderEventStyles = makeStyles()((theme: Theme) => ({ bar: { marginTop: theme.spacing(), @@ -35,4 +47,19 @@ export const useRenderEventStyles = makeStyles()((theme: Theme) => ({ color: theme.textColors.headlineStatic, textDecoration: 'none', }, + unseenEventV2: { + '&:after': { + backgroundColor: theme.palette.primary.main, + content: '""', + display: 'block', + height: '100%', + left: 0, + position: 'absolute', + top: 0, + width: 4, + }, + backgroundColor: theme.bg.offWhite, + borderBottom: `1px solid ${theme.bg.main}`, + position: 'relative', + }, })); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx index 1c9957f1f55..695adbd95df 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEvent.tsx @@ -1,3 +1,4 @@ +// TODO eventMessagesV2: delete when flag is removed import { Event } from '@linode/api-v4/lib/account/types'; import * as React from 'react'; @@ -5,8 +6,6 @@ import { Box } from 'src/components/Box'; import { Divider } from 'src/components/Divider'; import { HighlightedMarkdown } from 'src/components/HighlightedMarkdown/HighlightedMarkdown'; import { Typography } from 'src/components/Typography'; -import { getEventMessage } from 'src/features/Events/utils'; -import { useFlags } from 'src/hooks/useFlags'; import { getEventTimestamp } from 'src/utilities/eventUtils'; import { getAllowedHTMLTags } from 'src/utilities/sanitizeHTML.utils'; @@ -23,11 +22,9 @@ interface RenderEventProps { } export const RenderEvent = React.memo((props: RenderEventProps) => { - const flags = useFlags(); const { classes, cx } = useRenderEventStyles(); const { event } = props; const { message } = useEventInfo(event); - const messageV2 = getEventMessage(event); const unseenEventClass = cx({ [classes.unseenEvent]: !event.seen }); @@ -47,37 +44,6 @@ export const RenderEvent = React.memo((props: RenderEventProps) => { ); - if (flags.eventMessagesV2) { - return ( - /** - * Some event types may not be handled by our system (or new types or new ones may be added that we haven't caught yet). - * Filter these out so we don't display blank messages to the user. - * We have sentry events being logged for these cases, so we can always go back and add support for them as soon as aware. - */ - messageV2 ? ( - <> - - - - {messageV2} - - {getEventTimestamp(event).toRelative()} - {event.username && ` | ${event.username}`} - - - - - - ) : null - ); - } - return ( <> diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.test.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.test.tsx new file mode 100644 index 00000000000..cb7a08af07b --- /dev/null +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.test.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; + +import { eventFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { RenderEventV2 } from './RenderEventV2'; + +describe('RenderEventV2', () => { + it('should render a finished event with the proper data', () => { + const event = eventFactory.build({ + action: 'linode_create', + entity: { + id: 123, + label: 'test-linode', + }, + status: 'finished', + }); + + const { getByTestId, getByText } = renderWithTheme( + vi.fn()} /> + ); + + expect( + getByTestId('linode_create').textContent?.match( + /Linode test-linode has been created./ + ) + ); + expect( + getByText(/Started 1 second ago | prod-test-001/) + ).toBeInTheDocument(); + }); + + it('should redner an in progress event with the proper data', () => { + const event = eventFactory.build({ + action: 'linode_create', + entity: { + id: 123, + label: 'test-linode', + }, + percent_complete: 50, + status: 'started', + }); + + const { getByTestId, getByText } = renderWithTheme( + vi.fn()} /> + ); + + expect( + getByTestId('linode_create').textContent?.match( + /Linode test-linode is being created./ + ) + ); + expect( + getByText(/Started 1 second ago | prod-test-001/) + ).toBeInTheDocument(); + expect(getByTestId('linear-progress')).toHaveAttribute( + 'aria-valuenow', + '50' + ); + }); +}); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx new file mode 100644 index 00000000000..64ce3bbdb81 --- /dev/null +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; + +import { BarPercent } from 'src/components/BarPercent'; +import { Box } from 'src/components/Box'; +import { Divider } from 'src/components/Divider'; +import { Typography } from 'src/components/Typography'; +import { + formatProgressEvent, + getEventMessage, +} from 'src/features/Events/utils'; + +import { + RenderEventGravatarV2, + RenderEventStyledBox, + useRenderEventStyles, +} from './RenderEvent.styles'; + +import type { Event } from '@linode/api-v4/lib/account/types'; + +interface RenderEventProps { + event: Event; + onClose: () => void; +} + +export const RenderEventV2 = React.memo((props: RenderEventProps) => { + const { event } = props; + const { classes, cx } = useRenderEventStyles(); + const unseenEventClass = cx({ [classes.unseenEventV2]: !event.seen }); + const message = getEventMessage(event); + + /** + * Some event types may not be handled by our system (or new types or new ones may be added that we haven't caught yet). + * Filter these out so we don't display blank messages to the user. + * We have Sentry events being logged for these cases, so we can always go back and add support for them as soon as we become aware. + */ + if (message === null) { + return null; + } + + const { progressEventDisplay, showProgress } = formatProgressEvent(event); + + return ( + <> + + + + {message} + {showProgress && ( + + )} + + {progressEventDisplay} | {event.username ?? 'Linode'} + + + + + + ); +}); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx index e8f38ccfcec..0eb115ada46 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderProgressEvent.tsx @@ -1,4 +1,4 @@ -import { Event } from '@linode/api-v4/lib/account/types'; +// TODO eventMessagesV2: delete when flag is removed import { Duration } from 'luxon'; import * as React from 'react'; @@ -16,11 +16,13 @@ import { extendTypesQueryResult } from 'src/utilities/extendType'; import { isNotNullOrUndefined } from 'src/utilities/nullOrUndefined'; import { - RenderEventGravatar, + RenderEventGravatarV2, RenderEventStyledBox, useRenderEventStyles, } from './RenderEvent.styles'; +import type { Event } from '@linode/api-v4/lib/account/types'; + interface Props { event: Event; onClose: () => void; @@ -58,10 +60,7 @@ export const RenderProgressEvent = (props: Props) => { return ( <> - + { + // `profile_update` is a noisy event + // Any change to user preferences will trigger this event, so we filter it out at the API level + const { events } = useEventsInfiniteQuery(EVENTS_LIST_FILTER); + const notificationContext = React.useContext(_notificationContext); + + const formattedEvents = events?.map((event) => { + const { showProgress } = formatProgressEvent(event); + + return formatEventForDisplay({ + event, + onClose: notificationContext.closeMenu, + showProgress, + }); + }); + + return formattedEvents?.filter((event) => Boolean(event.body)) ?? []; +}; + +interface FormattedEventForDisplay { + event: Event; + onClose: () => void; + showProgress: boolean; +} + +const formatEventForDisplay = ({ + event, + onClose, +}: FormattedEventForDisplay): NotificationItem => ({ + body: , + countInTotal: !event.seen, + eventId: event.id, + id: `event-${event.id}`, + showProgress: formatProgressEvent(event).showProgress, +}); diff --git a/packages/manager/src/features/NotificationCenter/NotificationSection.tsx b/packages/manager/src/features/NotificationCenter/NotificationSection.tsx index 9646755d916..b32ebfbba2a 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationSection.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationSection.tsx @@ -42,6 +42,7 @@ export interface NotificationItem { countInTotal: boolean; eventId: number; id: string; + showProgress?: boolean; } interface NotificationSectionProps { @@ -50,6 +51,7 @@ interface NotificationSectionProps { emptyMessage?: string; header: string; loading?: boolean; + onCloseNotificationCenter?: () => void; showMoreTarget?: string; showMoreText?: string; } @@ -63,6 +65,7 @@ export const NotificationSection = (props: NotificationSectionProps) => { emptyMessage, header, loading, + onCloseNotificationCenter, showMoreTarget, showMoreText, } = props; @@ -88,7 +91,11 @@ export const NotificationSection = (props: NotificationSectionProps) => { {header} {showMoreTarget && ( - + {showMoreText ?? 'View history'} @@ -161,6 +168,7 @@ const ContentBody = React.memo((props: BodyProps) => { <> {_content.map((thisItem) => ( diff --git a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx index 72c033c80ec..baa052b1dec 100644 --- a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx +++ b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenu.tsx @@ -1,3 +1,4 @@ +// TODO eventMessagesV2: delete when flag is removed import { IconButton } from '@mui/material'; import Popover from '@mui/material/Popover'; import { styled } from '@mui/material/styles'; diff --git a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.test.tsx b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.test.tsx new file mode 100644 index 00000000000..ebb7e326c85 --- /dev/null +++ b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.test.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NotificationMenuV2 } from './NotificationMenuV2'; + +describe('NotificationMenuV2', () => { + // Very basic unit - the functionality is tested in the integration test + it('should render', () => { + const { getByRole } = renderWithTheme(); + + expect(getByRole('button', { name: 'Notifications' })).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.tsx b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.tsx new file mode 100644 index 00000000000..07c3ad98e27 --- /dev/null +++ b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.tsx @@ -0,0 +1,169 @@ +import AutorenewIcon from '@mui/icons-material/Autorenew'; +import { IconButton } from '@mui/material'; +import Popover from '@mui/material/Popover'; +import { styled } from '@mui/material/styles'; +import * as React from 'react'; + +import Bell from 'src/assets/icons/notification.svg'; +import { Chip } from 'src/components/Chip'; +import { EventsV2 } from 'src/features/NotificationCenter/EventsV2'; +import { + notificationContext as _notificationContext, + menuButtonId, +} from 'src/features/NotificationCenter/NotificationContext'; +import { useEventNotificationsV2 } from 'src/features/NotificationCenter/NotificationData/useEventNotificationsV2'; +import { useFormattedNotifications } from 'src/features/NotificationCenter/NotificationData/useFormattedNotifications'; +import Notifications from 'src/features/NotificationCenter/Notifications'; +import { useDismissibleNotifications } from 'src/hooks/useDismissibleNotifications'; +import { usePrevious } from 'src/hooks/usePrevious'; +import { useNotificationsQuery } from 'src/queries/account/notifications'; +import { useMarkEventsAsSeen } from 'src/queries/events/events'; +import { rotate360 } from 'src/styles/keyframes'; + +import { TopMenuTooltip, topMenuIconButtonSx } from '../TopMenuTooltip'; + +export const NotificationMenuV2 = () => { + const { dismissNotifications } = useDismissibleNotifications(); + const { data: notifications } = useNotificationsQuery(); + const formattedNotifications = useFormattedNotifications(); + const eventNotifications = useEventNotificationsV2(); + const notificationContext = React.useContext(_notificationContext); + const { mutateAsync: markEventsAsSeen } = useMarkEventsAsSeen(); + + const numNotifications = + eventNotifications.filter( + (notificationItem) => notificationItem.countInTotal + ).length + + formattedNotifications.filter( + (notificationItem) => notificationItem.countInTotal + ).length; + const showInProgressEventIcon = eventNotifications.some( + (notificationItem) => notificationItem.showProgress + ); + + const anchorRef = React.useRef(null); + const prevOpen = usePrevious(notificationContext.menuOpen); + + const handleNotificationMenuToggle = () => { + if (!notificationContext.menuOpen) { + notificationContext.openMenu(); + } else { + notificationContext.closeMenu(); + } + }; + + const handleClose = () => { + notificationContext.closeMenu(); + }; + + React.useEffect(() => { + if (prevOpen && !notificationContext.menuOpen) { + // Dismiss seen notifications after the menu has closed. + if (eventNotifications.length > 0) { + markEventsAsSeen(eventNotifications[0].eventId); + } + dismissNotifications(notifications ?? [], { prefix: 'notificationMenu' }); + } + }, [ + notificationContext.menuOpen, + dismissNotifications, + eventNotifications, + notifications, + prevOpen, + markEventsAsSeen, + ]); + + const id = notificationContext.menuOpen ? 'notifications-popover' : undefined; + + return ( + <> + + ({ + ...topMenuIconButtonSx(theme), + color: notificationContext.menuOpen ? '#606469' : '#c9c7c7', + })} + aria-describedby={id} + aria-haspopup="true" + aria-label="Notifications" + id={menuButtonId} + onClick={handleNotificationMenuToggle} + ref={anchorRef} + > + + {numNotifications > 0 && ( + 9 ? '9+' : numNotifications} + showPlus={numNotifications > 9} + size="small" + /> + )} + {showInProgressEventIcon && ( + + )} + + + ({ + maxHeight: 'calc(100vh - 150px)', + maxWidth: 430, + py: 2, + [theme.breakpoints.down('sm')]: { + left: '0 !important', + minWidth: '100%', + right: '0 !important', + }, + }), + }, + }} + anchorEl={anchorRef.current} + id={id} + onClose={handleClose} + open={notificationContext.menuOpen} + > + + notificationContext.closeMenu()} + /> + + + ); +}; + +const StyledChip = styled(Chip, { + label: 'StyledEventNotificationChip', + shouldForwardProp: (prop) => prop !== 'showPlus', +})<{ showPlus: boolean }>(({ theme, ...props }) => ({ + '& .MuiChip-label': { + paddingLeft: 2, + paddingRight: 2, + }, + borderRadius: props.showPlus ? 12 : '50%', + fontFamily: theme.font.bold, + fontSize: '0.72rem', + height: 18, + justifyContent: 'center', + left: 20, + padding: 0, + position: 'absolute', + top: 0, + width: props.showPlus ? 22 : 18, +})); + +const StyledAutorenewIcon = styled(AutorenewIcon)(({ theme }) => ({ + animation: `${rotate360} 2s linear infinite`, + bottom: 4, + color: theme.palette.primary.main, + fontSize: 18, + position: 'absolute', + right: 2, +})); diff --git a/packages/manager/src/features/TopMenu/TopMenu.tsx b/packages/manager/src/features/TopMenu/TopMenu.tsx index bc5c34fcdc1..f26072d765d 100644 --- a/packages/manager/src/features/TopMenu/TopMenu.tsx +++ b/packages/manager/src/features/TopMenu/TopMenu.tsx @@ -8,11 +8,13 @@ import { IconButton } from 'src/components/IconButton'; import { Toolbar } from 'src/components/Toolbar'; import { Typography } from 'src/components/Typography'; import { useAuthentication } from 'src/hooks/useAuthentication'; +import { useFlags } from 'src/hooks/useFlags'; import { AddNewMenu } from './AddNewMenu/AddNewMenu'; import { Community } from './Community'; import { Help } from './Help'; import { NotificationMenu } from './NotificationMenu/NotificationMenu'; +import { NotificationMenuV2 } from './NotificationMenu/NotificationMenuV2'; import SearchBar from './SearchBar/SearchBar'; import { TopMenuTooltip } from './TopMenuTooltip'; import { UserMenu } from './UserMenu'; @@ -30,6 +32,8 @@ export interface TopMenuProps { */ export const TopMenu = React.memo((props: TopMenuProps) => { const { desktopMenuToggle, isSideMenuOpen, openSideMenu, username } = props; + // TODO eventMessagesV2: delete when flag is removed + const flags = useFlags(); const { loggedInAsCustomer } = useAuthentication(); @@ -92,7 +96,11 @@ export const TopMenu = React.memo((props: TopMenuProps) => { - + {flags.eventMessagesV2 ? ( + + ) : ( + + )} From 22026a31eebbf10873684760344c954fd5de5d5d Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 14 Jun 2024 10:15:07 -0400 Subject: [PATCH 090/163] change: [M3-8242] - Update TypeScript to latest (#10573) * update typescript to latest and ignore deprecations * Added changeset: Update TypeScript to latest * fix eslint errors and remove unused code --------- Co-authored-by: Banks Nussman --- package.json | 2 +- .../pr-10573-tech-stories-1718213403351.md | 5 + packages/manager/package.json | 6 +- packages/manager/src/components/OrderBy.tsx | 4 +- .../Billing/PdfGenerator/PdfGenerator.ts | 2 +- .../LinodesLanding/SortableTableHead.tsx | 4 +- .../Linodes/LinodesLanding/TableWrapper.tsx | 2 +- .../EditRoutes/RouteAccordion.tsx | 1 + .../manager/src/store/store.helpers.test.ts | 153 -------- .../manager/src/store/store.helpers.tmp.ts | 213 ---------- packages/manager/src/store/store.helpers.ts | 164 +------- packages/manager/src/store/types.ts | 67 ---- packages/manager/tsconfig.json | 5 +- yarn.lock | 367 +++++++++++++----- 14 files changed, 282 insertions(+), 713 deletions(-) create mode 100644 packages/manager/.changeset/pr-10573-tech-stories-1718213403351.md delete mode 100644 packages/manager/src/store/store.helpers.test.ts delete mode 100644 packages/manager/src/store/store.helpers.tmp.ts diff --git a/package.json b/package.json index 4211ec10959..2804917ccba 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "npm-run-all": "^4.1.5", "patch-package": "^7.0.0", "postinstall": "^0.6.0", - "typescript": "^4.9.5" + "typescript": "^5.4.5" }, "husky": { "hooks": { diff --git a/packages/manager/.changeset/pr-10573-tech-stories-1718213403351.md b/packages/manager/.changeset/pr-10573-tech-stories-1718213403351.md new file mode 100644 index 00000000000..5b209448551 --- /dev/null +++ b/packages/manager/.changeset/pr-10573-tech-stories-1718213403351.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Update TypeScript to latest ([#10573](https://github.com/linode/manager/pull/10573)) diff --git a/packages/manager/package.json b/packages/manager/package.json index 5dc92325793..c6e0afd6552 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -165,8 +165,8 @@ "@types/uuid": "^3.4.3", "@types/yup": "^0.29.13", "@types/zxcvbn": "^4.4.0", - "@typescript-eslint/eslint-plugin": "^4.1.1", - "@typescript-eslint/parser": "^4.1.1", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", "@vitejs/plugin-react-swc": "^3.5.0", "@vitest/coverage-v8": "^1.6.0", "@vitest/ui": "^1.6.0", @@ -180,7 +180,7 @@ "cypress-real-events": "^1.12.0", "cypress-vite": "^1.5.0", "dotenv": "^16.0.3", - "eslint": "^6.8.0", + "eslint": "^7.1.0", "eslint-config-prettier": "~8.1.0", "eslint-plugin-cypress": "^2.11.3", "eslint-plugin-jsx-a11y": "^6.7.1", diff --git a/packages/manager/src/components/OrderBy.tsx b/packages/manager/src/components/OrderBy.tsx index f1fca35a051..dc05c89a622 100644 --- a/packages/manager/src/components/OrderBy.tsx +++ b/packages/manager/src/components/OrderBy.tsx @@ -98,7 +98,7 @@ export const getInitialValuesFromUserPreferences = ( ); }; -export const sortData = (orderBy: string, order: Order) => { +export const sortData = (orderBy: string, order: Order) => { return sort((a, b) => { /* If the column we're sorting on is an array (e.g. 'tags', which is string[]), * we want to sort by the length of the array. Otherwise, do a simple comparison. @@ -155,7 +155,7 @@ export const sortData = (orderBy: string, order: Order) => { }); }; -export const OrderBy = (props: CombinedProps) => { +export const OrderBy = (props: CombinedProps) => { const { data: preferences } = usePreferences(); const { mutateAsync: updatePreferences } = useMutatePreferences(); const location = useLocation(); diff --git a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts index 20dd0a0cf42..662080f614c 100644 --- a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts +++ b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts @@ -227,7 +227,7 @@ export const printInvoice = async ( unit: 'px', }); - const convertedInvoiceDate = invoice.date && dateConversion(invoice.date); + const convertedInvoiceDate = dateConversion(invoice.date); const TaxStartDate = taxes && taxes?.date ? dateConversion(taxes.date) : Infinity; diff --git a/packages/manager/src/features/Linodes/LinodesLanding/SortableTableHead.tsx b/packages/manager/src/features/Linodes/LinodesLanding/SortableTableHead.tsx index 5f802d9b583..0062283654a 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/SortableTableHead.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/SortableTableHead.tsx @@ -28,9 +28,7 @@ interface SortableTableHeadProps extends Props, Omit, 'data'> {} -export const SortableTableHead = ( - props: SortableTableHeadProps -) => { +export const SortableTableHead = (props: SortableTableHeadProps) => { const theme = useTheme(); const { diff --git a/packages/manager/src/features/Linodes/LinodesLanding/TableWrapper.tsx b/packages/manager/src/features/Linodes/LinodesLanding/TableWrapper.tsx index b35839852ff..5fa1e58287e 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/TableWrapper.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/TableWrapper.tsx @@ -19,7 +19,7 @@ interface Props { interface TableWrapperProps extends Omit, 'data'>, Props {} -const TableWrapper = (props: TableWrapperProps) => { +const TableWrapper = (props: TableWrapperProps) => { const { dataLength, handleOrderChange, diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerSummary/EditLoadBalancerConfigurations/EditRoutes/RouteAccordion.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerSummary/EditLoadBalancerConfigurations/EditRoutes/RouteAccordion.tsx index 41dfcb5c9a7..02d15b11ef6 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerSummary/EditLoadBalancerConfigurations/EditRoutes/RouteAccordion.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerSummary/EditLoadBalancerConfigurations/EditRoutes/RouteAccordion.tsx @@ -32,6 +32,7 @@ export const RouteAccordion = ({ configIndex, route, routeIndex }: Props) => { sx={{ backgroundColor: '#f4f5f6', paddingLeft: 1, paddingRight: 1.4 }} > {/* TODO ACLB: Implement RulesTable */} + <>Todo { - describe('getAddRemoved', () => { - const existingList = [{ id: '1' }, { id: 2 }, { id: 3 }]; - const newList = [{ id: 1 }, { id: '3' }, { id: 4 }]; - const result = getAddRemoved(existingList, newList); - - it('should return a list of new and removed items', () => { - const added = [{ id: 4 }]; - const removed = [{ id: 2 }]; - expect(result).toEqual([added, removed]); - }); - }); - - describe('createDefaultState', () => { - const result = createDefaultState(); - it('should return the unmodified defaultState', () => { - expect(result).toEqual({ - error: undefined, - items: [], - itemsById: {}, - lastUpdated: 0, - loading: false, - }); - }); - }); - - describe('removeMany', () => { - const state = createDefaultState({ - items: ['1', '2', '3'], - itemsById: { 1: { id: 1 }, 2: { id: 2 }, 3: { id: 3 } }, - }); - const result = removeMany(['2', '3'], state); - - it('should remove the object with the provided ID', () => { - expect(result).toEqual({ - ...state, - items: ['1'], - itemsById: { 1: { id: 1 } }, - }); - }); - }); - - describe('addMany', () => { - const state = createDefaultState({ - items: ['1', '2', '3'], - itemsById: { 1: { id: 1 }, 2: { id: 2 }, 3: { id: 3 } }, - }); - const result = addMany([{ id: 99 }, { id: 66 }], state); - - it('should remove the object with the provided ID', () => { - expect(result).toEqual({ - ...state, - items: ['1', '2', '3', '66', '99'], - itemsById: { - 1: { id: 1 }, - 2: { id: 2 }, - 3: { id: 3 }, - 66: { id: 66 }, - 99: { id: 99 }, - }, - }); - }); - }); - - describe('onError', () => { - const state = createDefaultState(); - const result = onError([{ reason: 'Something bad happened.' }], state); - - it('should update state with error and complete loading', () => { - expect(result).toEqual({ - ...createDefaultState(), - error: [{ reason: 'Something bad happened.' }], - loading: false, - }); - }); - }); - - describe('onGetAllSuccess', () => { - const state = createDefaultState(); - const result = onGetAllSuccess([{ id: 1 }, { id: 2 }], state); - - it('should finish loading', () => { - expect(result).toHaveProperty('loading', false); - }); - - it('should set items list', () => { - expect(result).toHaveProperty('items', ['1', '2']); - }); - - it('should set itemsById map', () => { - expect(result).toHaveProperty('itemsById', { - 1: { id: 1 }, - 2: { id: 2 }, - }); - }); - }); - - describe('onStart', () => { - const state = createDefaultState(); - const result = onStart(state); - - it('should set to true', () => { - expect(result).toHaveProperty('loading', true); - }); - }); - - describe('updateInPlace', () => { - interface TestEntity { - id: number; - status: 'active' | 'resizing'; - } - - const state = createDefaultState({ - items: ['1', '2', '3'], - itemsById: { - 1: { id: 1, status: 'active' }, - 2: { id: 2, status: 'active' }, - 3: { id: 3, status: 'active' }, - }, - }); - - const updateFn = (existing: TestEntity) => ({ - ...existing, - status: 'resizing', - }); - - it('should update the item when it exists in state', () => { - const updated = updateInPlace(1, updateFn, state); - expect(updated.itemsById[1].status).toBe('resizing'); - }); - - it('should not affect unspecified properties', () => { - const updated = updateInPlace(2, updateFn, state); - expect(updated.itemsById[2].id).toBe(2); - }); - - it('should return state as-is if the item with the given ID is not found', () => { - const updated = updateInPlace(4, updateFn, state); - expect(updated).toEqual(state); - }); - }); -}); diff --git a/packages/manager/src/store/store.helpers.tmp.ts b/packages/manager/src/store/store.helpers.tmp.ts deleted file mode 100644 index c9189e7cef6..00000000000 --- a/packages/manager/src/store/store.helpers.tmp.ts +++ /dev/null @@ -1,213 +0,0 @@ -// @todo rename this file to store.helpers when all reducers are using MappedEntityState2 -import { APIError } from '@linode/api-v4/lib/types'; -import { assoc, omit } from 'ramda'; -import { AsyncActionCreators } from 'typescript-fsa'; - -import { - Entity, - EntityError, - EntityMap, - MappedEntityState2 as MappedEntityState, - ThunkActionCreator, -} from 'src/store/types'; - -export const addEntityRecord = ( - result: EntityMap, - current: T -): EntityMap => assoc(String(current.id), current, result); - -export const onStart = (state: S) => - Object.assign({}, state, { error: { read: undefined }, loading: true }); - -export const onGetAllSuccess = ( - items: E[], - state: S, - results: number, - update: (e: E) => E = (i) => i -): S => - Object.assign({}, state, { - itemsById: items.reduce( - (itemsById, item) => ({ ...itemsById, [item.id]: update(item) }), - {} - ), - lastUpdated: Date.now(), - loading: false, - results, - }); - -export const setError = ( - error: EntityError, - state: MappedEntityState -) => { - return Object.assign({}, state, { error: { ...state.error, ...error } }); -}; - -export const onError = ( - error: E, - state: S -) => Object.assign({}, state, { error, loading: false }); - -export const createDefaultState = ( - override: Partial> = {}, - defaultError: O = {} as O -): MappedEntityState => ({ - error: defaultError as O, // @todo decide on better approach to error typing - itemsById: {}, - lastUpdated: 0, - loading: false, - results: 0, - ...override, -}); - -export const onDeleteSuccess = ( - id: number | string, - state: MappedEntityState -): MappedEntityState => { - return removeMany([String(id)], state); -}; - -export const onCreateOrUpdate = ( - entity: E, - state: MappedEntityState -): MappedEntityState => { - return addMany([entity], state); -}; - -export const removeMany = ( - list: string[], - state: MappedEntityState -): MappedEntityState => { - const itemsById = omit(list, state.itemsById); - - return { - ...state, - itemsById, - results: Object.keys(itemsById).length, - }; -}; - -export const addMany = ( - list: E[], - state: MappedEntityState, - results?: number -): MappedEntityState => { - const itemsById = list.reduce( - (map, item) => ({ ...map, [item.id]: item }), - state.itemsById - ); - - return { - ...state, - itemsById, - results: results ?? Object.keys(itemsById).length, - }; -}; - -/** - * Generates a list of entities added to an existing list, and a list of entities removed from an existing list. - */ -export const getAddRemoved = ( - existingList: E[] = [], - newList: E[] = [] -) => { - const existingIds = existingList.map(({ id }) => String(id)); - const newIds = newList.map(({ id }) => String(id)); - - const added = newList.filter(({ id }) => !existingIds.includes(String(id))); - - const removed = existingList.filter(({ id }) => !newIds.includes(String(id))); - - return [added, removed]; -}; - -export const onGetPageSuccess = ( - items: E[], - state: MappedEntityState, - results: number -): MappedEntityState => { - const isFullRequest = results === items.length; - const newState = addMany(items, state, results); - return isFullRequest - ? { - ...newState, - lastUpdated: Date.now(), - loading: false, - } - : { ...newState, loading: false }; -}; - -export const createRequestThunk = ( - actions: AsyncActionCreators, - request: (params: Req) => Promise -): ThunkActionCreator, Req> => { - return (params: Req) => async (dispatch) => { - const { done, failed, started } = actions; - - dispatch(started(params)); - - try { - const result = await request(params); - const doneAction = done({ params, result }); - dispatch(doneAction); - return result; - } catch (error) { - const failAction = failed({ error, params }); - dispatch(failAction); - return Promise.reject(error); - } - }; -}; - -export const updateInPlace = ( - id: number | string, - update: (e: E) => E, - state: MappedEntityState -) => { - const { itemsById } = state; - - // If this entity cannot be found in state, return the state as-is. - if (!itemsById[id]) { - return state; - } - - // Return the state as-is EXCEPT replacing the original entity with the updated entity. - const updated = update(itemsById[id]); - return { - ...state, - itemsById: { - ...itemsById, - [id]: updated, - }, - }; -}; - -// Given a nested state and an ID, ensures that MappedEntityState exists at the -// provided key. If the nested state already exists, return the state untouched. -// If it doesn't exist, initialize the state with `createDefaultState()`. -export const ensureInitializedNestedState = ( - state: Record, - id: number, - override: any = {} -) => { - if (!state[id]) { - state[id] = createDefaultState({ ...override, error: {} }); - } - return state; -}; - -export const apiResponseToMappedState = (data: T[]) => { - return data.reduce((acc, thisEntity) => { - acc[thisEntity.id] = thisEntity; - return acc; - }, {}); -}; - -export const onGetOneSuccess = ( - entity: E, - state: MappedEntityState -): MappedEntityState => - Object.assign({}, state, { - itemsById: { ...state.itemsById, [entity.id]: entity }, - loading: false, - results: Object.keys(state.itemsById).length, - }); diff --git a/packages/manager/src/store/store.helpers.ts b/packages/manager/src/store/store.helpers.ts index e0a0d9ac71d..702905afb04 100644 --- a/packages/manager/src/store/store.helpers.ts +++ b/packages/manager/src/store/store.helpers.ts @@ -1,121 +1,7 @@ -import { APIError } from '@linode/api-v4/lib/types'; -import { assoc, omit } from 'ramda'; -import { AsyncActionCreators } from 'typescript-fsa'; +import type { ThunkActionCreator } from 'src/store/types'; +import type { AsyncActionCreators } from 'typescript-fsa'; -import { - Entity, - EntityMap, - MappedEntityState, - ThunkActionCreator, -} from 'src/store/types'; - -/** ID's are all mapped to string. */ -export const mapIDs = (e: { id: number | string }) => String(e.id); -const keys = Object.keys; - -export const addEntityRecord = ( - result: EntityMap, - current: T -): EntityMap => assoc(String(current.id), current, result); - -export const onStart = (state: S) => - Object.assign({}, state, { error: undefined, loading: true }); - -export const onGetAllSuccess = ( - items: E[], - state: S, - update: (e: E) => E = (i) => i -): S => - Object.assign({}, state, { - items: items.map(mapIDs), - itemsById: items.reduce( - (itemsById, item) => ({ ...itemsById, [item.id]: update(item) }), - {} - ), - lastUpdated: Date.now(), - loading: false, - }); - -export const onError = ( - error: E, - state: S -) => Object.assign({}, state, { error, loading: false }); - -export const createDefaultState = < - E extends Entity, - O = APIError[] | undefined ->( - override: Partial> = {} -): MappedEntityState => ({ - error: undefined, - items: [], - itemsById: {}, - lastUpdated: 0, - loading: false, - ...override, -}); - -export const onDeleteSuccess = ( - id: number | string, - state: MappedEntityState -): MappedEntityState => { - return removeMany([String(id)], state); -}; - -export const onCreateOrUpdate = ( - entity: E, - state: MappedEntityState -): MappedEntityState => { - return addMany([entity], state); -}; - -export const removeMany = ( - list: string[], - state: MappedEntityState -): MappedEntityState => { - const itemsById = omit(list, state.itemsById); - - return { - ...state, - items: keys(itemsById), - itemsById, - }; -}; - -export const addMany = ( - list: E[], - state: MappedEntityState -): MappedEntityState => { - const itemsById = list.reduce( - (map, item) => ({ ...map, [item.id]: item }), - state.itemsById - ); - - return { - ...state, - items: keys(itemsById), - itemsById, - }; -}; - -/** - * Generates a list of entities added to an existing list, and a list of entities removed from an existing list. - */ -export const getAddRemoved = ( - existingList: E[] = [], - newList: E[] = [] -) => { - const existingIds = existingList.map(({ id }) => String(id)); - const newIds = newList.map(({ id }) => String(id)); - - const added = newList.filter(({ id }) => !existingIds.includes(String(id))); - - const removed = existingList.filter(({ id }) => !newIds.includes(String(id))); - - return [added, removed]; -}; - -export const createRequestThunk = ( +export const createRequestThunk = ( actions: AsyncActionCreators, request: (params: Req) => Promise ): ThunkActionCreator, Req> => { @@ -136,47 +22,3 @@ export const createRequestThunk = ( } }; }; - -export const updateInPlace = ( - id: number | string, - update: (e: E) => E, - state: MappedEntityState -) => { - const { itemsById } = state; - - // If this entity cannot be found in state, return the state as-is. - if (!itemsById[id]) { - return state; - } - - // Return the state as-is EXCEPT replacing the original entity with the updated entity. - const updated = update(itemsById[id]); - return { - ...state, - itemsById: { - ...itemsById, - [id]: updated, - }, - }; -}; - -// Given a nested state and an ID, ensures that MappedEntityState exists at the -// provided key. If the nested state already exists, return the state untouched. -// If it doesn't exist, initialize the state with `createDefaultState()`. -export const ensureInitializedNestedState = ( - state: Record, - id: number, - override: any = {} -) => { - if (!state[id]) { - state[id] = createDefaultState({ ...override, error: {} }); - } - return state; -}; - -export const apiResponseToMappedState = (data: T[]) => { - return data.reduce((acc, thisEntity) => { - acc[thisEntity.id] = thisEntity; - return acc; - }, {}); -}; diff --git a/packages/manager/src/store/types.ts b/packages/manager/src/store/types.ts index 43e680b2565..e56031924a2 100644 --- a/packages/manager/src/store/types.ts +++ b/packages/manager/src/store/types.ts @@ -34,73 +34,6 @@ export type ThunkDispatch = _ThunkDispatch; export type MapState = _MapStateToProps; -export interface HasStringID { - id: string; -} - -export interface HasNumericID { - id: number; -} - -export type Entity = HasNumericID | HasStringID; - -export type TypeOfID = T extends HasNumericID ? number : string; - -export type EntityMap = Record; - -export interface MappedEntityState< - T extends Entity, - E = APIError[] | undefined -> { - error?: E; - items: string[]; - itemsById: EntityMap; - lastUpdated: number; - loading: boolean; -} - -// NOTE: These 2 interfaces are as of 2/26/2020 what we intend to consolidate around -export interface MappedEntityState2 { - error: E; - itemsById: Record; - lastUpdated: number; - loading: boolean; - results: number; -} - -export type RelationalMappedEntityState = Record< - number | string, - MappedEntityState2 ->; - -export interface EntityState { - entities: T[]; - error?: E; - lastUpdated: number; - loading: boolean; - results: TypeOfID[]; -} - -export interface RequestableData { - data?: D; - error?: E; - lastUpdated: number; - loading: boolean; -} - -// Rename to RequestableData and delete above when all components are using this pattern -export interface RequestableDataWithEntityError { - data?: D; - error: EntityError; - lastUpdated: number; - loading: boolean; - results?: number; -} - -export interface RequestableRequiredData extends RequestableData { - data: D; -} - export type EventHandler = ( event: EntityEvent, dispatch: Dispatch, diff --git a/packages/manager/tsconfig.json b/packages/manager/tsconfig.json index c1f82661284..d0ea28fb501 100644 --- a/packages/manager/tsconfig.json +++ b/packages/manager/tsconfig.json @@ -33,9 +33,12 @@ "noImplicitThis": true, "noUnusedLocals": true, "strictNullChecks": true, - "suppressImplicitAnyIndexErrors": true, "types": ["vitest/globals", "@testing-library/jest-dom"], + /* Goodluck... */ + "ignoreDeprecations": "5.0", + "suppressImplicitAnyIndexErrors": true, + /* Completeness */ "skipLibCheck": true, diff --git a/yarn.lock b/yarn.lock index 10ca9a89bb6..603a8dfc998 100644 --- a/yarn.lock +++ b/yarn.lock @@ -131,6 +131,13 @@ dependencies: default-browser-id "3.0.0" +"@babel/code-frame@7.12.11": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" + integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== + dependencies: + "@babel/highlight" "^7.10.4" + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.23.5": version "7.23.5" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.23.5.tgz#9009b69a8c602293476ad598ff53e4562e15c244" @@ -552,6 +559,16 @@ "@babel/traverse" "^7.24.5" "@babel/types" "^7.24.5" +"@babel/highlight@^7.10.4", "@babel/highlight@^7.24.7": + version "7.24.7" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.7.tgz#a05ab1df134b286558aae0ed41e6c5f731bf409d" + integrity sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw== + dependencies: + "@babel/helper-validator-identifier" "^7.24.7" + chalk "^2.4.2" + js-tokens "^4.0.0" + picocolors "^1.0.0" + "@babel/highlight@^7.23.4": version "7.23.4" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.23.4.tgz#edaadf4d8232e1a961432db785091207ead0621b" @@ -571,16 +588,6 @@ js-tokens "^4.0.0" picocolors "^1.0.0" -"@babel/highlight@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.7.tgz#a05ab1df134b286558aae0ed41e6c5f731bf409d" - integrity sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw== - dependencies: - "@babel/helper-validator-identifier" "^7.24.7" - chalk "^2.4.2" - js-tokens "^4.0.0" - picocolors "^1.0.0" - "@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.23.0", "@babel/parser@^7.23.6", "@babel/parser@^7.23.9", "@babel/parser@^7.7.0": version "7.23.9" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.9.tgz#7b903b6149b0f8fa7ad564af646c4c38a77fc44b" @@ -1985,18 +1992,38 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz#9c907b21e30a52db959ba4f80bb01a0cc403d5cc" integrity sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ== -"@eslint-community/eslint-utils@^4.2.0": +"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== dependencies: eslint-visitor-keys "^3.3.0" +"@eslint-community/regexpp@^4.5.1": + version "4.10.1" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.1.tgz#361461e5cb3845d874e61731c11cfedd664d83a0" + integrity sha512-Zm2NGpWELsQAD1xsJzGQpYfvICSsFkEpU0jxBjfdC6uNEWXcHnfs9hScFWtXVDVl+rBQJGrl4g1vcKIejpH9dA== + "@eslint-community/regexpp@^4.6.1": version "4.10.0" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.0.tgz#548f6de556857c8bb73bbee70c35dc82a2e74d63" integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA== +"@eslint/eslintrc@^0.4.3": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c" + integrity sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw== + dependencies: + ajv "^6.12.4" + debug "^4.1.1" + espree "^7.3.0" + globals "^13.9.0" + ignore "^4.0.6" + import-fresh "^3.2.1" + js-yaml "^3.13.1" + minimatch "^3.0.4" + strip-json-comments "^3.1.1" + "@eslint/eslintrc@^2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" @@ -2063,11 +2090,25 @@ debug "^4.3.1" minimatch "^3.0.5" +"@humanwhocodes/config-array@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9" + integrity sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg== + dependencies: + "@humanwhocodes/object-schema" "^1.2.0" + debug "^4.1.1" + minimatch "^3.0.4" + "@humanwhocodes/module-importer@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== +"@humanwhocodes/object-schema@^1.2.0": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" + integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== + "@humanwhocodes/object-schema@^2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz#d9fae00a2d5cb40f92cfe64b47ad749fbc38f917" @@ -3981,7 +4022,7 @@ "@types/tough-cookie" "*" parse5 "^7.0.0" -"@types/json-schema@^7.0.3", "@types/json-schema@^7.0.7", "@types/json-schema@^7.0.9": +"@types/json-schema@^7.0.12", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -4292,6 +4333,11 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.6.tgz#c65b2bfce1bec346582c07724e3f8c1017a20339" integrity sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A== +"@types/semver@^7.5.0": + version "7.5.8" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" + integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ== + "@types/send@*": version "0.17.4" resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.4.tgz#6619cd24e7270793702e4e6a4b958a9010cfc57a" @@ -4388,31 +4434,22 @@ resolved "https://registry.yarnpkg.com/@types/zxcvbn/-/zxcvbn-4.4.4.tgz#987f5fcd87e957097433c476c3a1c91a54f53131" integrity sha512-Tuk4q7q0DnpzyJDI4aMeghGuFu2iS1QAdKpabn8JfbtfGmVDUgvZv1I7mEjP61Bvnp3ljKCC8BE6YYSTNxmvRQ== -"@typescript-eslint/eslint-plugin@^4.1.1": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.33.0.tgz#c24dc7c8069c7706bc40d99f6fa87edcb2005276" - integrity sha512-aINiAxGVdOl1eJyVjaWn/YcVAq4Gi/Yo35qHGCnqbWVz61g39D0h23veY/MA0rFFGfxK7TySg2uwDeNv+JgVpg== - dependencies: - "@typescript-eslint/experimental-utils" "4.33.0" - "@typescript-eslint/scope-manager" "4.33.0" - debug "^4.3.1" - functional-red-black-tree "^1.0.1" - ignore "^5.1.8" - regexpp "^3.1.0" - semver "^7.3.5" - tsutils "^3.21.0" - -"@typescript-eslint/experimental-utils@4.33.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.33.0.tgz#6f2a786a4209fa2222989e9380b5331b2810f7fd" - integrity sha512-zeQjOoES5JFjTnAhI5QY7ZviczMzDptls15GFsI6jyUOq0kOf9+WonkhtlIhh0RgHRnqj5gdNxW5j1EvAyYg6Q== +"@typescript-eslint/eslint-plugin@^6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz#30830c1ca81fd5f3c2714e524c4303e0194f9cd3" + integrity sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA== dependencies: - "@types/json-schema" "^7.0.7" - "@typescript-eslint/scope-manager" "4.33.0" - "@typescript-eslint/types" "4.33.0" - "@typescript-eslint/typescript-estree" "4.33.0" - eslint-scope "^5.1.1" - eslint-utils "^3.0.0" + "@eslint-community/regexpp" "^4.5.1" + "@typescript-eslint/scope-manager" "6.21.0" + "@typescript-eslint/type-utils" "6.21.0" + "@typescript-eslint/utils" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" + debug "^4.3.4" + graphemer "^1.4.0" + ignore "^5.2.4" + natural-compare "^1.4.0" + semver "^7.5.4" + ts-api-utils "^1.0.1" "@typescript-eslint/experimental-utils@^3.10.1": version "3.10.1" @@ -4425,23 +4462,16 @@ eslint-scope "^5.0.0" eslint-utils "^2.0.0" -"@typescript-eslint/parser@^4.1.1": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.33.0.tgz#dfe797570d9694e560528d18eecad86c8c744899" - integrity sha512-ZohdsbXadjGBSK0/r+d87X0SBmKzOq4/S5nzK6SBgJspFo9/CUDJ7hjayuze+JK7CZQLDMroqytp7pOcFKTxZA== - dependencies: - "@typescript-eslint/scope-manager" "4.33.0" - "@typescript-eslint/types" "4.33.0" - "@typescript-eslint/typescript-estree" "4.33.0" - debug "^4.3.1" - -"@typescript-eslint/scope-manager@4.33.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.33.0.tgz#d38e49280d983e8772e29121cf8c6e9221f280a3" - integrity sha512-5IfJHpgTsTZuONKbODctL4kKuQje/bzBRkwHE8UOZ4f89Zeddg+EGZs8PD8NcN4LdM3ygHWYB3ukPAYjvl/qbQ== +"@typescript-eslint/parser@^6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.21.0.tgz#af8fcf66feee2edc86bc5d1cf45e33b0630bf35b" + integrity sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ== dependencies: - "@typescript-eslint/types" "4.33.0" - "@typescript-eslint/visitor-keys" "4.33.0" + "@typescript-eslint/scope-manager" "6.21.0" + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/typescript-estree" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" + debug "^4.3.4" "@typescript-eslint/scope-manager@5.62.0": version "5.62.0" @@ -4451,21 +4481,39 @@ "@typescript-eslint/types" "5.62.0" "@typescript-eslint/visitor-keys" "5.62.0" +"@typescript-eslint/scope-manager@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz#ea8a9bfc8f1504a6ac5d59a6df308d3a0630a2b1" + integrity sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg== + dependencies: + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" + +"@typescript-eslint/type-utils@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz#6473281cfed4dacabe8004e8521cee0bd9d4c01e" + integrity sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag== + dependencies: + "@typescript-eslint/typescript-estree" "6.21.0" + "@typescript-eslint/utils" "6.21.0" + debug "^4.3.4" + ts-api-utils "^1.0.1" + "@typescript-eslint/types@3.10.1": version "3.10.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-3.10.1.tgz#1d7463fa7c32d8a23ab508a803ca2fe26e758727" integrity sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ== -"@typescript-eslint/types@4.33.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.33.0.tgz#a1e59036a3b53ae8430ceebf2a919dc7f9af6d72" - integrity sha512-zKp7CjQzLQImXEpLt2BUw1tvOMPfNoTAfb8l51evhYbOEEzdWyQNmHWWGPR6hwKJDAi+1VXSBmnhL9kyVTTOuQ== - "@typescript-eslint/types@5.62.0", "@typescript-eslint/types@^5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f" integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ== +"@typescript-eslint/types@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.21.0.tgz#205724c5123a8fef7ecd195075fa6e85bac3436d" + integrity sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg== + "@typescript-eslint/typescript-estree@3.10.1": version "3.10.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-3.10.1.tgz#fd0061cc38add4fad45136d654408569f365b853" @@ -4480,19 +4528,6 @@ semver "^7.3.2" tsutils "^3.17.1" -"@typescript-eslint/typescript-estree@4.33.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.33.0.tgz#0dfb51c2908f68c5c08d82aefeaf166a17c24609" - integrity sha512-rkWRY1MPFzjwnEVHsxGemDzqqddw2QbTJlICPD9p9I9LfsO8fdmfQPOX3uKfUaGRDFJbfrtm/sXhVXN4E+bzCA== - dependencies: - "@typescript-eslint/types" "4.33.0" - "@typescript-eslint/visitor-keys" "4.33.0" - debug "^4.3.1" - globby "^11.0.3" - is-glob "^4.0.1" - semver "^7.3.5" - tsutils "^3.21.0" - "@typescript-eslint/typescript-estree@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz#7d17794b77fabcac615d6a48fb143330d962eb9b" @@ -4506,6 +4541,33 @@ semver "^7.3.7" tsutils "^3.21.0" +"@typescript-eslint/typescript-estree@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz#c47ae7901db3b8bddc3ecd73daff2d0895688c46" + integrity sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ== + dependencies: + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/visitor-keys" "6.21.0" + debug "^4.3.4" + globby "^11.1.0" + is-glob "^4.0.3" + minimatch "9.0.3" + semver "^7.5.4" + ts-api-utils "^1.0.1" + +"@typescript-eslint/utils@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-6.21.0.tgz#4714e7a6b39e773c1c8e97ec587f520840cd8134" + integrity sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ== + dependencies: + "@eslint-community/eslint-utils" "^4.4.0" + "@types/json-schema" "^7.0.12" + "@types/semver" "^7.5.0" + "@typescript-eslint/scope-manager" "6.21.0" + "@typescript-eslint/types" "6.21.0" + "@typescript-eslint/typescript-estree" "6.21.0" + semver "^7.5.4" + "@typescript-eslint/utils@^5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.62.0.tgz#141e809c71636e4a75daa39faed2fb5f4b10df86" @@ -4527,14 +4589,6 @@ dependencies: eslint-visitor-keys "^1.1.0" -"@typescript-eslint/visitor-keys@4.33.0": - version "4.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.33.0.tgz#2a22f77a41604289b7a186586e9ec48ca92ef1dd" - integrity sha512-uqi/2aSz9g2ftcHWf8uLPJA70rUv6yuMW5Bohw+bwcuzaxQIHaKFZCKGoGXIrc9vkTJ3+0txM73K0Hq3d5wgIg== - dependencies: - "@typescript-eslint/types" "4.33.0" - eslint-visitor-keys "^2.0.0" - "@typescript-eslint/visitor-keys@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz#2174011917ce582875954ffe2f6912d5931e353e" @@ -4543,6 +4597,14 @@ "@typescript-eslint/types" "5.62.0" eslint-visitor-keys "^3.3.0" +"@typescript-eslint/visitor-keys@6.21.0": + version "6.21.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz#87a99d077aa507e20e238b11d56cc26ade45fe47" + integrity sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A== + dependencies: + "@typescript-eslint/types" "6.21.0" + eslint-visitor-keys "^3.4.1" + "@ungap/structured-clone@^1.0.0", "@ungap/structured-clone@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" @@ -4692,7 +4754,7 @@ acorn-walk@^8.1.1, acorn-walk@^8.3.2: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.2.tgz#7703af9415f1b6db9315d6895503862e231d34aa" integrity sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A== -acorn@^7.1.1, acorn@^7.4.1: +acorn@^7.1.1, acorn@^7.4.0, acorn@^7.4.1: version "7.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== @@ -4742,6 +4804,16 @@ ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^8.0.1: + version "8.16.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.16.0.tgz#22e2a92b94f005f7e0f9c9d39652ef0b8f6f0cb4" + integrity sha512-F0twR8U1ZU67JIEtekUcLkXkoO5mMMmgGD8sK/xUFzJ805jxHQl92hImFAqqXMyMYjSPOyUPAwHYhB72g5sTXw== + dependencies: + fast-deep-equal "^3.1.3" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.4.1" + algoliasearch@^4.14.3: version "4.22.1" resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-4.22.1.tgz#f10fbecdc7654639ec20d62f109c1b3a46bc6afc" @@ -6614,7 +6686,7 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: dependencies: once "^1.4.0" -enquirer@^2.3.6: +enquirer@^2.3.5, enquirer@^2.3.6: version "2.4.1" resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.4.1.tgz#93334b3fbd74fc7097b224ab4a8fb7e40bf4ae56" integrity sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ== @@ -7019,21 +7091,14 @@ eslint-utils@^1.4.3: dependencies: eslint-visitor-keys "^1.1.0" -eslint-utils@^2.0.0: +eslint-utils@^2.0.0, eslint-utils@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== dependencies: eslint-visitor-keys "^1.1.0" -eslint-utils@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-3.0.0.tgz#8aebaface7345bb33559db0a1f13a1d2d48c3672" - integrity sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA== - dependencies: - eslint-visitor-keys "^2.0.0" - -eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0: +eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== @@ -7135,6 +7200,52 @@ eslint@^6.8.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" +eslint@^7.1.0: + version "7.32.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d" + integrity sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA== + dependencies: + "@babel/code-frame" "7.12.11" + "@eslint/eslintrc" "^0.4.3" + "@humanwhocodes/config-array" "^0.5.0" + ajv "^6.10.0" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.0.1" + doctrine "^3.0.0" + enquirer "^2.3.5" + escape-string-regexp "^4.0.0" + eslint-scope "^5.1.1" + eslint-utils "^2.1.0" + eslint-visitor-keys "^2.0.0" + espree "^7.3.1" + esquery "^1.4.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + functional-red-black-tree "^1.0.1" + glob-parent "^5.1.2" + globals "^13.6.0" + ignore "^4.0.6" + import-fresh "^3.0.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + js-yaml "^3.13.1" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.0.4" + natural-compare "^1.4.0" + optionator "^0.9.1" + progress "^2.0.0" + regexpp "^3.1.0" + semver "^7.2.1" + strip-ansi "^6.0.0" + strip-json-comments "^3.1.0" + table "^6.0.9" + text-table "^0.2.0" + v8-compile-cache "^2.0.3" + espree@^6.1.2: version "6.2.1" resolved "https://registry.yarnpkg.com/espree/-/espree-6.2.1.tgz#77fc72e1fd744a2052c20f38a5b575832e82734a" @@ -7144,6 +7255,15 @@ espree@^6.1.2: acorn-jsx "^5.2.0" eslint-visitor-keys "^1.1.0" +espree@^7.3.0, espree@^7.3.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6" + integrity sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g== + dependencies: + acorn "^7.4.0" + acorn-jsx "^5.3.1" + eslint-visitor-keys "^1.3.0" + espree@^9.6.0, espree@^9.6.1: version "9.6.1" resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" @@ -7158,7 +7278,7 @@ esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.0.1, esquery@^1.4.2: +esquery@^1.0.1, esquery@^1.4.0, esquery@^1.4.2: version "1.5.0" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== @@ -7950,7 +8070,7 @@ globals@^12.1.0: dependencies: type-fest "^0.8.1" -globals@^13.19.0, globals@^13.20.0: +globals@^13.19.0, globals@^13.20.0, globals@^13.6.0, globals@^13.9.0: version "13.24.0" resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== @@ -8295,7 +8415,7 @@ ignore@^4.0.6: resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== -ignore@^5.1.1, ignore@^5.1.8, ignore@^5.2.0, ignore@^5.2.4: +ignore@^5.1.1, ignore@^5.2.0, ignore@^5.2.4: version "5.3.1" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef" integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw== @@ -9341,6 +9461,11 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA== +lodash.truncate@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" + integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw== + lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" @@ -10035,6 +10160,13 @@ minimatch@3.1.2, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch dependencies: brace-expansion "^1.1.7" +minimatch@9.0.3, minimatch@^9.0.1, minimatch@^9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" + integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== + dependencies: + brace-expansion "^2.0.1" + minimatch@^5.0.1: version "5.1.6" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" @@ -10042,13 +10174,6 @@ minimatch@^5.0.1: dependencies: brace-expansion "^2.0.1" -minimatch@^9.0.1, minimatch@^9.0.3: - version "9.0.3" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" - integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== - dependencies: - brace-expansion "^2.0.1" - minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" @@ -10475,6 +10600,18 @@ optionator@^0.8.3: type-check "~0.3.2" word-wrap "~1.2.3" +optionator@^0.9.1: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + optionator@^0.9.3: version "0.9.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.3.tgz#007397d44ed1872fdc6ed31360190f81814e2c64" @@ -12026,7 +12163,7 @@ semver-compare@^1.0.0: resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== -"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.1, semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3: +"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4: version "7.6.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== @@ -12594,7 +12731,7 @@ strip-indent@^4.0.0: dependencies: min-indent "^1.0.1" -strip-json-comments@^3.0.1, strip-json-comments@^3.1.1: +strip-json-comments@^3.0.1, strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== @@ -12699,6 +12836,17 @@ table@^5.2.3: slice-ansi "^2.1.0" string-width "^3.0.0" +table@^6.0.9: + version "6.8.2" + resolved "https://registry.yarnpkg.com/table/-/table-6.8.2.tgz#c5504ccf201213fa227248bdc8c5569716ac6c58" + integrity sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA== + dependencies: + ajv "^8.0.1" + lodash.truncate "^4.4.2" + slice-ansi "^4.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + tar-fs@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784" @@ -12940,6 +13088,11 @@ trough@^2.0.0: resolved "https://registry.yarnpkg.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f" integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw== +ts-api-utils@^1.0.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1" + integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ== + ts-dedent@^2.0.0, ts-dedent@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5" @@ -13152,10 +13305,10 @@ typescript-fsa@^3.0.0: resolved "https://registry.yarnpkg.com/typescript-fsa/-/typescript-fsa-3.0.0.tgz#3ad1cb915a67338e013fc21f67c9b3e0e110c912" integrity sha512-xiXAib35i0QHl/+wMobzPibjAH5TJLDj+qGq5jwVLG9qR4FUswZURBw2qihBm0m06tHoyb3FzpnJs1GRhRwVag== -typescript@^4.9.5: - version "4.9.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" - integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== +typescript@^5.4.5: + version "5.4.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611" + integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== ua-parser-js@^0.7.30, ua-parser-js@^0.7.33: version "0.7.38" @@ -13317,7 +13470,7 @@ update-check@1.5.4: registry-auth-token "3.3.2" registry-url "3.1.0" -uri-js@^4.2.2: +uri-js@^4.2.2, uri-js@^4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== @@ -13706,7 +13859,7 @@ widest-line@^4.0.1: dependencies: string-width "^5.0.1" -word-wrap@^1.2.4, word-wrap@~1.2.3: +word-wrap@^1.2.4, word-wrap@^1.2.5, word-wrap@~1.2.3: version "1.2.5" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== From b18db95b3aa9f948d4666fb8bd98e516e5ed5763 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Fri, 14 Jun 2024 10:32:49 -0400 Subject: [PATCH 091/163] =?UTF-8?q?upcoming:=20[M3-8020]=20=E2=80=93=20Add?= =?UTF-8?q?=20"Disk=20Encryption"=20section=20to=20Linode=20Rebuild=20moda?= =?UTF-8?q?l=20(#10549)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...r-10549-upcoming-features-1718120005131.md | 5 ++ .../components/AccessPanel/AccessPanel.tsx | 77 ++++++++++++++++++- .../components/DiskEncryption/constants.tsx | 15 ++++ .../src/features/Events/factories/tax.tsx | 6 +- .../LinodeRebuild/LinodeRebuildDialog.tsx | 39 +++++++++- .../LinodeRebuild/RebuildFromImage.test.tsx | 50 +++++++++++- .../LinodeRebuild/RebuildFromImage.tsx | 22 ++++++ .../RebuildFromStackScript.test.tsx | 54 ++++++++++++- .../LinodeRebuild/RebuildFromStackScript.tsx | 29 +++++++ packages/manager/src/mocks/serverHandlers.ts | 17 ++-- 10 files changed, 298 insertions(+), 16 deletions(-) create mode 100644 packages/manager/.changeset/pr-10549-upcoming-features-1718120005131.md diff --git a/packages/manager/.changeset/pr-10549-upcoming-features-1718120005131.md b/packages/manager/.changeset/pr-10549-upcoming-features-1718120005131.md new file mode 100644 index 00000000000..16a668a7064 --- /dev/null +++ b/packages/manager/.changeset/pr-10549-upcoming-features-1718120005131.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Disk Encryption section to Linode Rebuild modal ([#10549](https://github.com/linode/manager/pull/10549)) diff --git a/packages/manager/src/components/AccessPanel/AccessPanel.tsx b/packages/manager/src/components/AccessPanel/AccessPanel.tsx index f0b8d393b00..bbe75e27f54 100644 --- a/packages/manager/src/components/AccessPanel/AccessPanel.tsx +++ b/packages/manager/src/components/AccessPanel/AccessPanel.tsx @@ -1,10 +1,14 @@ -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { DISK_ENCRYPTION_GENERAL_DESCRIPTION, DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY, + ENCRYPT_DISK_DISABLED_REBUILD_DISTRIBUTED_REGION_REASON, + ENCRYPT_DISK_DISABLED_REBUILD_LKE_REASON, + ENCRYPT_DISK_REBUILD_DISTRIBUTED_COPY, + ENCRYPT_DISK_REBUILD_LKE_COPY, + ENCRYPT_DISK_REBUILD_STANDARD_COPY, } from 'src/components/DiskEncryption/constants'; import { DiskEncryption } from 'src/components/DiskEncryption/DiskEncryption'; import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; @@ -17,6 +21,8 @@ import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature import { Divider } from '../Divider'; import UserSSHKeyPanel from './UserSSHKeyPanel'; +import type { Theme } from '@mui/material/styles'; + const PasswordInput = React.lazy( () => import('src/components/PasswordInput/PasswordInput') ); @@ -46,8 +52,11 @@ interface Props { handleChange: (value: string) => void; heading?: string; hideStrengthLabel?: boolean; + isInRebuildFlow?: boolean; + isLKELinode?: boolean; isOptional?: boolean; label?: string; + linodeIsInDistributedRegion?: boolean; password: null | string; passwordHelperText?: string; placeholder?: string; @@ -69,8 +78,11 @@ export const AccessPanel = (props: Props) => { error, handleChange: _handleChange, hideStrengthLabel, + isInRebuildFlow, + isLKELinode, isOptional, label, + linodeIsInDistributedRegion, password, passwordHelperText, placeholder, @@ -97,6 +109,48 @@ export const AccessPanel = (props: Props) => { const handleChange = (e: React.ChangeEvent) => _handleChange(e.target.value); + const determineRebuildFlowDiskEncryptionDescription = ({ + isLKELinode, + linodeIsInDistributedRegion, + }: { + isLKELinode: boolean | undefined; + linodeIsInDistributedRegion: boolean | undefined; + }) => { + if (isLKELinode) { + return ENCRYPT_DISK_REBUILD_LKE_COPY; + } + + if (linodeIsInDistributedRegion) { + return ENCRYPT_DISK_REBUILD_DISTRIBUTED_COPY; + } + + return ENCRYPT_DISK_REBUILD_STANDARD_COPY; + }; + + const determineEncryptDiskDisabledReason = ({ + isLKELinode, + linodeIsInDistributedRegion, + regionSupportsDiskEncryption, + }: { + isLKELinode: boolean | undefined; + linodeIsInDistributedRegion: boolean | undefined; + regionSupportsDiskEncryption: boolean; + }) => { + if (isLKELinode) { + return ENCRYPT_DISK_DISABLED_REBUILD_LKE_REASON; + } + + if (linodeIsInDistributedRegion) { + return ENCRYPT_DISK_DISABLED_REBUILD_DISTRIBUTED_REGION_REASON; + } + + if (!regionSupportsDiskEncryption) { + return DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY; + } + + return ''; + }; + /** * Display the "Disk Encryption" section if: * 1) the feature is enabled @@ -111,9 +165,24 @@ export const AccessPanel = (props: Props) => { <> toggleDiskEncryptionEnabled()} /> diff --git a/packages/manager/src/components/DiskEncryption/constants.tsx b/packages/manager/src/components/DiskEncryption/constants.tsx index a49b31115c5..7e6ffafd759 100644 --- a/packages/manager/src/components/DiskEncryption/constants.tsx +++ b/packages/manager/src/components/DiskEncryption/constants.tsx @@ -46,3 +46,18 @@ export const DISK_ENCRYPTION_BACKUPS_CAVEAT_COPY = export const DISK_ENCRYPTION_IMAGES_CAVEAT_COPY = 'Virtual Machine Images are not encrypted.'; + +export const ENCRYPT_DISK_DISABLED_REBUILD_LKE_REASON = + 'The Encrypt Disk setting cannot be changed for a Linode attached to a node pool.'; + +export const ENCRYPT_DISK_DISABLED_REBUILD_DISTRIBUTED_REGION_REASON = + 'The Encrypt Disk setting cannot be changed for distributed instances.'; + +export const ENCRYPT_DISK_REBUILD_STANDARD_COPY = + 'Secure this Linode using data at rest encryption.'; + +export const ENCRYPT_DISK_REBUILD_LKE_COPY = + 'Secure this Linode using data at rest encryption. The disk encryption setting for Linodes added to a node pool will not be changed after rebuild.'; + +export const ENCRYPT_DISK_REBUILD_DISTRIBUTED_COPY = + 'Distributed Compute Instances are secured using disk encryption.'; diff --git a/packages/manager/src/features/Events/factories/tax.tsx b/packages/manager/src/features/Events/factories/tax.tsx index f107718a8e1..5ac7cb45211 100644 --- a/packages/manager/src/features/Events/factories/tax.tsx +++ b/packages/manager/src/features/Events/factories/tax.tsx @@ -4,6 +4,10 @@ import type { PartialEventMap } from '../types'; export const tax: PartialEventMap<'tax'> = { tax_id_invalid: { - notification: () => <>Tax Identification Number format is invalid., + notification: () => ( + <> + Tax Identification Number format is invalid. + + ), }, }; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx index 715c8a7b878..2e10b041038 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx @@ -2,17 +2,21 @@ import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; import { Dialog } from 'src/components/Dialog/Dialog'; -import EnhancedSelect, { Item } from 'src/components/EnhancedSelect/Select'; +import EnhancedSelect from 'src/components/EnhancedSelect/Select'; import { Notice } from 'src/components/Notice/Notice'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { Typography } from 'src/components/Typography'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; import { useGrants, useProfile } from 'src/queries/profile/profile'; +import { useRegionsQuery } from 'src/queries/regions/regions'; import { HostMaintenanceError } from '../HostMaintenanceError'; import { LinodePermissionsError } from '../LinodePermissionsError'; import { RebuildFromImage } from './RebuildFromImage'; import { RebuildFromStackScript } from './RebuildFromStackScript'; +import type { Item } from 'src/components/EnhancedSelect/Select'; + interface Props { linodeId: number | undefined; onClose: () => void; @@ -42,6 +46,8 @@ export const LinodeRebuildDialog = (props: Props) => { linodeId !== undefined && open ); + const { data: regionsData } = useRegionsQuery(); + const isReadOnly = Boolean(profile?.restricted) && grants?.linode.find((grant) => grant.id === linodeId)?.permissions === @@ -51,11 +57,24 @@ export const LinodeRebuildDialog = (props: Props) => { const unauthorized = isReadOnly; const disabled = hostMaintenance || unauthorized; + // LDE-related checks + const isEncrypted = linode?.disk_encryption === 'enabled'; + const isLKELinode = Boolean(linode?.lke_cluster_id); + const linodeIsInDistributedRegion = getIsDistributedRegion( + regionsData ?? [], + linode?.region ?? '' + ); + const theme = useTheme(); const [mode, setMode] = React.useState('fromImage'); const [rebuildError, setRebuildError] = React.useState(''); + const [ + diskEncryptionEnabled, + setDiskEncryptionEnabled, + ] = React.useState(isEncrypted); + const onExitDrawer = () => { setRebuildError(''); setMode('fromImage'); @@ -65,6 +84,10 @@ export const LinodeRebuildDialog = (props: Props) => { setRebuildError(status); }; + const toggleDiskEncryptionEnabled = () => { + setDiskEncryptionEnabled(!diskEncryptionEnabled); + }; + return ( { {mode === 'fromImage' && ( )} {mode === 'fromCommunityStackScript' && ( )} {mode === 'fromAccountStackScript' && ( )} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.test.tsx index 9874a1a6dcc..645a4c8f48a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.test.tsx @@ -2,7 +2,7 @@ import { render } from '@testing-library/react'; import * as React from 'react'; import { reactRouterProps } from 'src/__data__/reactRouterProps'; -import { wrapWithTheme } from 'src/utilities/testHelpers'; +import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; import { RebuildFromImage } from './RebuildFromImage'; @@ -11,18 +11,66 @@ vi.mock('src/components/EnhancedSelect/Select'); const props = { disabled: false, + diskEncryptionEnabled: true, handleRebuildError: vi.fn(), + isLKELinode: false, linodeId: 1234, + linodeIsInDistributedRegion: false, onClose: vi.fn(), passwordHelperText: '', + toggleDiskEncryptionEnabled: vi.fn(), ...reactRouterProps, }; +const diskEncryptionEnabledMock = vi.hoisted(() => { + return { + useIsDiskEncryptionFeatureEnabled: vi.fn(), + }; +}); + describe('RebuildFromImage', () => { + vi.mock('src/components/DiskEncryption/utils.ts', async () => { + const actual = await vi.importActual( + 'src/components/DiskEncryption/utils.ts' + ); + return { + ...actual, + __esModule: true, + useIsDiskEncryptionFeatureEnabled: diskEncryptionEnabledMock.useIsDiskEncryptionFeatureEnabled.mockImplementation( + () => { + return { + isDiskEncryptionFeatureEnabled: false, // indicates the feature flag is off or account capability is absent + }; + } + ), + }; + }); + it('renders a SelectImage panel', () => { const { queryByText } = render( wrapWithTheme() ); expect(queryByText('Select Image')).toBeInTheDocument(); }); + + // @TODO LDE: Remove feature flagging/conditionality once LDE is fully rolled out + it('does not render a "Disk Encryption" section when the Disk Encryption feature is disabled', () => { + const { queryByText } = renderWithTheme(); + + expect(queryByText('Encrypt Disk')).not.toBeInTheDocument(); + }); + + it('renders a "Disk Encryption" section when the Disk Encryption feature is enabled', () => { + diskEncryptionEnabledMock.useIsDiskEncryptionFeatureEnabled.mockImplementationOnce( + () => { + return { + isDiskEncryptionFeatureEnabled: true, + }; + } + ); + + const { queryByText } = renderWithTheme(); + + expect(queryByText('Encrypt Disk')).toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx index 25bb9766d3c..38799062f1e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx @@ -41,12 +41,16 @@ import { interface Props { disabled: boolean; + diskEncryptionEnabled: boolean; handleRebuildError: (status: string) => void; + isLKELinode: boolean; linodeId: number; + linodeIsInDistributedRegion: boolean; linodeLabel?: string; linodeRegion?: string; onClose: () => void; passwordHelperText: string; + toggleDiskEncryptionEnabled: () => void; } interface RebuildFromImageForm { @@ -70,12 +74,16 @@ export const REBUILD_LINODE_IMAGE_PARAM_NAME = 'selectedImageId'; export const RebuildFromImage = (props: Props) => { const { disabled, + diskEncryptionEnabled, handleRebuildError, + isLKELinode, linodeId, + linodeIsInDistributedRegion, linodeLabel, linodeRegion, onClose, passwordHelperText, + toggleDiskEncryptionEnabled, } = props; const { @@ -140,6 +148,7 @@ export const RebuildFromImage = (props: Props) => { const params: RebuildRequest = { authorized_users, + disk_encryption: diskEncryptionEnabled ? 'enabled' : 'disabled', image, metadata: { user_data: userData @@ -162,6 +171,12 @@ export const RebuildFromImage = (props: Props) => { delete params['metadata']; } + // if the linode is part of an LKE cluster or is in a Distributed region, the disk_encryption value + // cannot be changed, so omit it from the payload + if (isLKELinode || linodeIsInDistributedRegion) { + delete params['disk_encryption']; + } + // @todo: eventually this should be a dispatched action instead of a services library call rebuildLinode(linodeId, params) .then((_) => { @@ -256,10 +271,17 @@ export const RebuildFromImage = (props: Props) => { authorizedUsers={values.authorized_users} data-qa-access-panel disabled={disabled} + diskEncryptionEnabled={diskEncryptionEnabled} + displayDiskEncryption error={errors.root_pass} handleChange={(input) => setFieldValue('root_pass', input)} + isInRebuildFlow + isLKELinode={isLKELinode} + linodeIsInDistributedRegion={linodeIsInDistributedRegion} password={values.root_pass} passwordHelperText={passwordHelperText} + selectedRegion={linodeRegion} + toggleDiskEncryptionEnabled={toggleDiskEncryptionEnabled} /> {shouldDisplayUserDataAccordion ? ( <> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.test.tsx index 90f8c6e06c9..64926320596 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.test.tsx @@ -2,21 +2,48 @@ import { fireEvent, render, waitFor } from '@testing-library/react'; import * as React from 'react'; import { reactRouterProps } from 'src/__data__/reactRouterProps'; -import { wrapWithTheme } from 'src/utilities/testHelpers'; +import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; import { RebuildFromStackScript } from './RebuildFromStackScript'; const props = { disabled: false, + diskEncryptionEnabled: true, handleRebuildError: vi.fn(), + isLKELinode: false, linodeId: 1234, + linodeIsInDistributedRegion: false, onClose: vi.fn(), passwordHelperText: '', + toggleDiskEncryptionEnabled: vi.fn(), type: 'community' as const, ...reactRouterProps, }; +const diskEncryptionEnabledMock = vi.hoisted(() => { + return { + useIsDiskEncryptionFeatureEnabled: vi.fn(), + }; +}); + describe('RebuildFromStackScript', () => { + vi.mock('src/components/DiskEncryption/utils.ts', async () => { + const actual = await vi.importActual( + 'src/components/DiskEncryption/utils.ts' + ); + return { + ...actual, + __esModule: true, + useIsDiskEncryptionFeatureEnabled: diskEncryptionEnabledMock.useIsDiskEncryptionFeatureEnabled.mockImplementation( + () => { + return { + isDiskEncryptionFeatureEnabled: false, // indicates the feature flag is off or account capability is absent + }; + } + ), + }; + }); + it('renders a SelectImage panel', () => { const { queryByText } = render( wrapWithTheme() @@ -45,4 +72,29 @@ describe('RebuildFromStackScript', () => { {} ); }); + + // @TODO LDE: Remove feature flagging/conditionality once LDE is fully rolled out + it('does not render a "Disk Encryption" section when the Disk Encryption feature is disabled', () => { + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Encrypt Disk')).not.toBeInTheDocument(); + }); + + it('renders a "Disk Encryption" section when the Disk Encryption feature is enabled', () => { + diskEncryptionEnabledMock.useIsDiskEncryptionFeatureEnabled.mockImplementationOnce( + () => { + return { + isDiskEncryptionFeatureEnabled: true, + }; + } + ); + + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Encrypt Disk')).toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx index fe6641fa1ce..5639ab211c7 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx @@ -37,16 +37,22 @@ import { extendValidationSchema } from 'src/utilities/validatePassword'; interface Props { disabled: boolean; + diskEncryptionEnabled: boolean; handleRebuildError: (status: string) => void; + isLKELinode: boolean; linodeId: number; + linodeIsInDistributedRegion: boolean; linodeLabel?: string; + linodeRegion?: string; onClose: () => void; passwordHelperText: string; + toggleDiskEncryptionEnabled: () => void; type: 'account' | 'community'; } interface RebuildFromStackScriptForm { authorized_users: string[]; + disk_encryption: string | undefined; image: string; root_pass: string; stackscript_id: string; @@ -54,6 +60,7 @@ interface RebuildFromStackScriptForm { const initialValues: RebuildFromStackScriptForm = { authorized_users: [], + disk_encryption: 'enabled', image: '', root_pass: '', stackscript_id: '', @@ -61,11 +68,16 @@ const initialValues: RebuildFromStackScriptForm = { export const RebuildFromStackScript = (props: Props) => { const { + diskEncryptionEnabled, handleRebuildError, + isLKELinode, linodeId, + linodeIsInDistributedRegion, linodeLabel, + linodeRegion, onClose, passwordHelperText, + toggleDiskEncryptionEnabled, } = props; const { @@ -120,8 +132,18 @@ export const RebuildFromStackScript = (props: Props) => { ) => { setSubmitting(true); + // if the linode is part of an LKE cluster or is in a Distributed region, the disk_encryption value + // cannot be changed, so set it to undefined and the API will disregard it + const diskEncryptionPayloadValue = + isLKELinode || linodeIsInDistributedRegion + ? undefined + : diskEncryptionEnabled + ? 'enabled' + : 'disabled'; + rebuildLinode(linodeId, { authorized_users, + disk_encryption: diskEncryptionPayloadValue, image, root_pass, stackscript_data: ss.udf_data, @@ -307,10 +329,17 @@ export const RebuildFromStackScript = (props: Props) => { } authorizedUsers={values.authorized_users} data-qa-access-panel + diskEncryptionEnabled={diskEncryptionEnabled} + displayDiskEncryption error={errors.root_pass} handleChange={(value) => setFieldValue('root_pass', value)} + isInRebuildFlow + isLKELinode={isLKELinode} + linodeIsInDistributedRegion={linodeIsInDistributedRegion} password={values.root_pass} passwordHelperText={passwordHelperText} + selectedRegion={linodeRegion} + toggleDiskEncryptionEnabled={toggleDiskEncryptionEnabled} /> ({ diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index f9511523f12..b03b647e342 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1,11 +1,3 @@ -import { - NotificationType, - ObjectStorageKeyRequest, - SecurityQuestionsPayload, - TokenRequest, - User, - VolumeStatus, -} from '@linode/api-v4'; import { DateTime } from 'luxon'; import { HttpResponse, http } from 'msw'; @@ -108,6 +100,15 @@ import { grantFactory, grantsFactory } from 'src/factories/grants'; import { pickRandom } from 'src/utilities/random'; import { getStorage } from 'src/utilities/storage'; +import type { + NotificationType, + ObjectStorageKeyRequest, + SecurityQuestionsPayload, + TokenRequest, + User, + VolumeStatus, +} from '@linode/api-v4'; + export const makeResourcePage = ( e: T[], override: { page: number; pages: number; results?: number } = { From a5f18c793631d2a41d917d331f068be331aa5c01 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Fri, 14 Jun 2024 14:37:23 -0400 Subject: [PATCH 092/163] =?UTF-8?q?upcoming:=20[M3-8145]=20=E2=80=93=20Upd?= =?UTF-8?q?ate=20LDE=20copy=20in=20Linode=20Create=20flow=20when=20Distrib?= =?UTF-8?q?uted=20region=20is=20selected=20(#10576)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...r-10576-upcoming-features-1718227220058.md | 5 + .../components/AccessPanel/AccessPanel.tsx | 107 ++++++++++++------ .../components/DiskEncryption/constants.tsx | 15 ++- .../Linodes/LinodeCreatev2/Security.test.tsx | 2 +- .../Linodes/LinodeCreatev2/Security.tsx | 20 +++- 5 files changed, 108 insertions(+), 41 deletions(-) create mode 100644 packages/manager/.changeset/pr-10576-upcoming-features-1718227220058.md diff --git a/packages/manager/.changeset/pr-10576-upcoming-features-1718227220058.md b/packages/manager/.changeset/pr-10576-upcoming-features-1718227220058.md new file mode 100644 index 00000000000..b5d9b29f95d --- /dev/null +++ b/packages/manager/.changeset/pr-10576-upcoming-features-1718227220058.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Revise LDE copy in Linode Create flow when Distributed region is selected ([#10576](https://github.com/linode/manager/pull/10576)) diff --git a/packages/manager/src/components/AccessPanel/AccessPanel.tsx b/packages/manager/src/components/AccessPanel/AccessPanel.tsx index bbe75e27f54..f21c9e146da 100644 --- a/packages/manager/src/components/AccessPanel/AccessPanel.tsx +++ b/packages/manager/src/components/AccessPanel/AccessPanel.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { + DISK_ENCRYPTION_DEFAULT_DISTRIBUTED_INSTANCES, + DISK_ENCRYPTION_DISTRIBUTED_DESCRIPTION, DISK_ENCRYPTION_GENERAL_DESCRIPTION, DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY, ENCRYPT_DISK_DISABLED_REBUILD_DISTRIBUTED_REGION_REASON, @@ -13,6 +15,7 @@ import { import { DiskEncryption } from 'src/components/DiskEncryption/DiskEncryption'; import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; import { Paper } from 'src/components/Paper'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { Typography } from 'src/components/Typography'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -67,6 +70,21 @@ interface Props { toggleDiskEncryptionEnabled?: () => void; } +interface DiskEncryptionDescriptionDeterminants { + isDistributedRegion: boolean | undefined; // Linode Create flow (region selected for a not-yet-created linode) + isInRebuildFlow: boolean | undefined; + isLKELinode: boolean | undefined; + linodeIsInDistributedRegion: boolean | undefined; // Linode Rebuild flow (linode exists already) +} + +interface DiskEncryptionDisabledReasonDeterminants { + isDistributedRegion: boolean | undefined; // Linode Create flow (region selected for a not-yet-created linode) + isInRebuildFlow: boolean | undefined; + isLKELinode: boolean | undefined; + linodeIsInDistributedRegion: boolean | undefined; // Linode Rebuild flow (linode exists already) + regionSupportsDiskEncryption: boolean; +} + export const AccessPanel = (props: Props) => { const { authorizedUsers, @@ -106,49 +124,68 @@ export const AccessPanel = (props: Props) => { 'Disk Encryption' ); + const isDistributedRegion = getIsDistributedRegion( + regions ?? [], + selectedRegion ?? '' + ); + const handleChange = (e: React.ChangeEvent) => _handleChange(e.target.value); - const determineRebuildFlowDiskEncryptionDescription = ({ + const determineDiskEncryptionDescription = ({ + isDistributedRegion, + isInRebuildFlow, isLKELinode, linodeIsInDistributedRegion, - }: { - isLKELinode: boolean | undefined; - linodeIsInDistributedRegion: boolean | undefined; - }) => { - if (isLKELinode) { - return ENCRYPT_DISK_REBUILD_LKE_COPY; - } + }: DiskEncryptionDescriptionDeterminants) => { + // Linode Rebuild flow descriptions + if (isInRebuildFlow) { + // the order is significant: all Distributed instances are encrypted (broadest) + if (linodeIsInDistributedRegion) { + return ENCRYPT_DISK_REBUILD_DISTRIBUTED_COPY; + } - if (linodeIsInDistributedRegion) { - return ENCRYPT_DISK_REBUILD_DISTRIBUTED_COPY; + if (isLKELinode) { + return ENCRYPT_DISK_REBUILD_LKE_COPY; + } + + if (!isLKELinode && !linodeIsInDistributedRegion) { + return ENCRYPT_DISK_REBUILD_STANDARD_COPY; + } } - return ENCRYPT_DISK_REBUILD_STANDARD_COPY; + // Linode Create flow descriptions + return isDistributedRegion + ? DISK_ENCRYPTION_DISTRIBUTED_DESCRIPTION + : DISK_ENCRYPTION_GENERAL_DESCRIPTION; }; - const determineEncryptDiskDisabledReason = ({ + const determineDiskEncryptionDisabledReason = ({ + isDistributedRegion, + isInRebuildFlow, isLKELinode, linodeIsInDistributedRegion, regionSupportsDiskEncryption, - }: { - isLKELinode: boolean | undefined; - linodeIsInDistributedRegion: boolean | undefined; - regionSupportsDiskEncryption: boolean; - }) => { - if (isLKELinode) { - return ENCRYPT_DISK_DISABLED_REBUILD_LKE_REASON; - } + }: DiskEncryptionDisabledReasonDeterminants) => { + if (isInRebuildFlow) { + // the order is significant: setting can't be changed for *any* Distributed instances (broadest) + if (linodeIsInDistributedRegion) { + return ENCRYPT_DISK_DISABLED_REBUILD_DISTRIBUTED_REGION_REASON; + } - if (linodeIsInDistributedRegion) { - return ENCRYPT_DISK_DISABLED_REBUILD_DISTRIBUTED_REGION_REASON; - } + if (isLKELinode) { + return ENCRYPT_DISK_DISABLED_REBUILD_LKE_REASON; + } - if (!regionSupportsDiskEncryption) { - return DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY; + if (!regionSupportsDiskEncryption) { + return DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY; + } } - return ''; + // Linode Create flow disabled reasons + return isDistributedRegion + ? DISK_ENCRYPTION_DEFAULT_DISTRIBUTED_INSTANCES + : DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY; }; /** @@ -165,20 +202,20 @@ export const AccessPanel = (props: Props) => { <> Secure this Linode using data at rest encryption. Data center systems take care of encrypting and decrypting for you. After the Linode is created, use - Rebuild to enable or disable this feature. Learn more. + Rebuild to enable or disable this feature.{' '} + Learn more. ); +export const DISK_ENCRYPTION_DISTRIBUTED_DESCRIPTION = + 'Distributed Compute Instances are secured using disk encryption. Encryption and decryption are automatically managed for you.'; + const DISK_ENCRYPTION_UPDATE_PROTECT_CLUSTERS_DOCS_LINK = 'https://www.linode.com/docs/products/compute/compute-instances/guides/local-disk-encryption/'; @@ -28,7 +34,10 @@ export const DISK_ENCRYPTION_UPDATE_PROTECT_CLUSTERS_BANNER_KEY = 'disk-encryption-update-protect-clusters-banner'; export const DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY = - 'Disk encryption is not available in the selected region.'; + 'Disk encryption is not available in the selected region. Select another region to use Disk Encryption.'; + +export const DISK_ENCRYPTION_DEFAULT_DISTRIBUTED_INSTANCES = + 'Distributed Compute Instances are encrypted. This setting can not be changed.'; // Guidance export const DISK_ENCRYPTION_NODE_POOL_GUIDANCE_COPY = diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx index 001869bcf75..acd014cd307 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.test.tsx @@ -156,7 +156,7 @@ describe('Security', () => { }); await findByLabelText( - 'Disk encryption is not available in the selected region.' + 'Disk encryption is not available in the selected region. Select another region to use Disk Encryption.' ); }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Security.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.tsx index e4059238d48..916632806b6 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Security.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Security.tsx @@ -3,6 +3,8 @@ import { Controller, useFormContext, useWatch } from 'react-hook-form'; import UserSSHKeyPanel from 'src/components/AccessPanel/UserSSHKeyPanel'; import { + DISK_ENCRYPTION_DEFAULT_DISTRIBUTED_INSTANCES, + DISK_ENCRYPTION_DISTRIBUTED_DESCRIPTION, DISK_ENCRYPTION_GENERAL_DESCRIPTION, DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY, } from 'src/components/DiskEncryption/constants'; @@ -10,6 +12,7 @@ import { DiskEncryption } from 'src/components/DiskEncryption/DiskEncryption'; import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; import { Divider } from 'src/components/Divider'; import { Paper } from 'src/components/Paper'; +import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { Skeleton } from 'src/components/Skeleton'; import { Typography } from 'src/components/Typography'; import { inputMaxWidth } from 'src/foundations/themes/light'; @@ -38,6 +41,11 @@ export const Security = () => { 'Disk Encryption' ); + const isDistributedRegion = getIsDistributedRegion( + regions ?? [], + selectedRegion?.id ?? '' + ); + const isLinodeCreateRestricted = useRestrictedGlobalGrantCheck({ globalGrantType: 'add_linodes', }); @@ -87,12 +95,20 @@ export const Security = () => { ( field.onChange(checked ? 'enabled' : 'disabled') } - descriptionCopy={DISK_ENCRYPTION_GENERAL_DESCRIPTION} disabled={!regionSupportsDiskEncryption} - disabledReason={DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY} error={fieldState.error?.message} isEncryptDiskChecked={field.value === 'enabled'} /> From f1a02f9efcbd3fa8d0118af4569bfe0ff8c233e8 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Mon, 17 Jun 2024 08:51:57 -0400 Subject: [PATCH 093/163] test: Mock profile to fix GHA test failure (#10585) * Mock TFA to be disabled to fix failure when asserting security question warning * Added changeset: Mock profile request to improve security questions test stability --- .../manager/.changeset/pr-10585-tests-1718380830458.md | 5 +++++ .../cypress/e2e/core/account/security-questions.spec.ts | 7 +++++++ 2 files changed, 12 insertions(+) create mode 100644 packages/manager/.changeset/pr-10585-tests-1718380830458.md diff --git a/packages/manager/.changeset/pr-10585-tests-1718380830458.md b/packages/manager/.changeset/pr-10585-tests-1718380830458.md new file mode 100644 index 00000000000..caef6949ece --- /dev/null +++ b/packages/manager/.changeset/pr-10585-tests-1718380830458.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Mock profile request to improve security questions test stability ([#10585](https://github.com/linode/manager/pull/10585)) diff --git a/packages/manager/cypress/e2e/core/account/security-questions.spec.ts b/packages/manager/cypress/e2e/core/account/security-questions.spec.ts index c11b1121398..aab84a220d3 100644 --- a/packages/manager/cypress/e2e/core/account/security-questions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/security-questions.spec.ts @@ -2,8 +2,10 @@ * @file Integration tests for account security questions. */ +import { profileFactory } from 'src/factories/profile'; import { securityQuestionsFactory } from 'src/factories/profile'; import { + mockGetProfile, mockGetSecurityQuestions, mockUpdateSecurityQuestions, } from 'support/intercepts/profile'; @@ -117,6 +119,10 @@ describe('Account security questions', () => { const securityQuestions = securityQuestionsFactory.build(); const securityQuestionAnswers = ['Answer 1', 'Answer 2', 'Answer 3']; + const mockProfile = profileFactory.build({ + two_factor_auth: false, + }); + const securityQuestionsPayload = { security_questions: [ { question_id: 1, response: securityQuestionAnswers[0] }, @@ -128,6 +134,7 @@ describe('Account security questions', () => { const tfaSecurityQuestionsWarning = 'To use two-factor authentication you must set up your security questions listed below.'; + mockGetProfile(mockProfile); mockGetSecurityQuestions(securityQuestions).as('getSecurityQuestions'); mockUpdateSecurityQuestions(securityQuestionsPayload).as( 'setSecurityQuestions' From fb9c2d2f1632669ab0417c05d22a580802d8e8f9 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Mon, 17 Jun 2024 10:12:29 -0400 Subject: [PATCH 094/163] =?UTF-8?q?upcoming:=20[M3-8245]=20=E2=80=93=20Upd?= =?UTF-8?q?ate=20"Add=20Node=20Pools"=20description=20in=20LKE=20Create=20?= =?UTF-8?q?flow=20(#10578)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...r-10578-upcoming-features-1718295945732.md | 5 +++ .../Kubernetes/ClusterList/constants.ts | 2 + .../CreateCluster/NodePoolPanel.tsx | 42 ++++++++++++++----- 3 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 packages/manager/.changeset/pr-10578-upcoming-features-1718295945732.md create mode 100644 packages/manager/src/features/Kubernetes/ClusterList/constants.ts diff --git a/packages/manager/.changeset/pr-10578-upcoming-features-1718295945732.md b/packages/manager/.changeset/pr-10578-upcoming-features-1718295945732.md new file mode 100644 index 00000000000..5f2b63c140c --- /dev/null +++ b/packages/manager/.changeset/pr-10578-upcoming-features-1718295945732.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Update description for Add Node Pools section in LKE Create flow ([#10578](https://github.com/linode/manager/pull/10578)) diff --git a/packages/manager/src/features/Kubernetes/ClusterList/constants.ts b/packages/manager/src/features/Kubernetes/ClusterList/constants.ts new file mode 100644 index 00000000000..a7cd5ef9b09 --- /dev/null +++ b/packages/manager/src/features/Kubernetes/ClusterList/constants.ts @@ -0,0 +1,2 @@ +export const ADD_NODE_POOLS_DESCRIPTION = + 'Add groups of Linodes to your cluster. You can have a maximum of 250 Linodes per node pool. Node Pool data is encrypted at rest.'; diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx index aab2e592cbc..867b89ceea4 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx @@ -1,13 +1,23 @@ -import { KubeNodePoolResponse, LinodeTypeClass, Region } from '@linode/api-v4'; import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { CircleProgress } from 'src/components/CircleProgress'; +import { useIsDiskEncryptionFeatureEnabled } from 'src/components/DiskEncryption/utils'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; -import { ExtendedType, extendType } from 'src/utilities/extendType'; +import { useRegionsQuery } from 'src/queries/regions/regions'; +import { doesRegionSupportFeature } from 'src/utilities/doesRegionSupportFeature'; +import { extendType } from 'src/utilities/extendType'; +import { ADD_NODE_POOLS_DESCRIPTION } from '../ClusterList/constants'; import { KubernetesPlansPanel } from '../KubernetesPlansPanel/KubernetesPlansPanel'; +import type { + KubeNodePoolResponse, + LinodeTypeClass, + Region, +} from '@linode/api-v4'; +import type { ExtendedType } from 'src/utilities/extendType'; + const DEFAULT_PLAN_COUNT = 3; export interface NodePoolPanelProps { @@ -23,15 +33,11 @@ export interface NodePoolPanelProps { typesLoading: boolean; } -export const NodePoolPanel: React.FunctionComponent = ( - props -) => { +export const NodePoolPanel = (props: NodePoolPanelProps) => { return ; }; -const RenderLoadingOrContent: React.FunctionComponent = ( - props -) => { +const RenderLoadingOrContent = (props: NodePoolPanelProps) => { const { typesError, typesLoading } = props; if (typesError) { @@ -45,7 +51,7 @@ const RenderLoadingOrContent: React.FunctionComponent = ( return ; }; -const Panel: React.FunctionComponent = (props) => { +const Panel = (props: NodePoolPanelProps) => { const { addNodePool, apiError, @@ -57,6 +63,12 @@ const Panel: React.FunctionComponent = (props) => { types, } = props; + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); + + const regions = useRegionsQuery().data ?? []; + const [typeCountMap, setTypeCountMap] = React.useState>( new Map() ); @@ -81,17 +93,27 @@ const Panel: React.FunctionComponent = (props) => { setSelectedType(planId); }; + const regionSupportsDiskEncryption = doesRegionSupportFeature( + selectedRegionId ?? '', + regions, + 'Disk Encryption' + ); + return ( typeCountMap.get(planId) ?? DEFAULT_PLAN_COUNT } types={extendedTypes.filter( (t) => t.class !== 'nanode' && t.class !== 'gpu' )} // No Nanodes or GPUs in clusters - copy="Add groups of Linodes to your cluster. You can have a maximum of 100 Linodes per node pool." error={apiError} hasSelectedRegion={hasSelectedRegion} header="Add Node Pools" From b078966b7ddaf07e08652177b743d7a819ad4539 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 17 Jun 2024 12:29:26 -0400 Subject: [PATCH 095/163] change: [M3-7646] - Link Cloud Manager README to new documentation pages (#10582) * Link Cloud Manager documentation to new pages * formatting * Added changeset: Link Cloud Manager README to new documentation pages --- README.md | 4 ++-- .../manager/.changeset/pr-10582-changed-1718310753730.md | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-10582-changed-1718310753730.md diff --git a/README.md b/README.md index 1d2c50db13e..942c886ab92 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,11 @@ This repository is home to the Akamai Connected **[Cloud Manager](https://cloud. ## Developing Locally -To get started running Cloud Manager locally, please see the [_Getting Started_ guide](docs/GETTING_STARTED.md). +To get started running Cloud Manager locally, please see the [Getting Started guide](https://linode.github.io/manager/GETTING_STARTED.html). ## Contributing -If you already have your development environment set up, please read the [contributing guidelines](docs/CONTRIBUTING.md) to get help in creating your first Pull Request. +If you already have your development environment set up, please read the [Contributing Guidelines](https://linode.github.io/manager/CONTRIBUTING.html) to get help in creating your first Pull Request. To report a bug or request a feature in Cloud Manager, please [open a GitHub Issue](https://github.com/linode/manager/issues/new). For general feedback, use [linode.com/feedback](https://www.linode.com/feedback/). diff --git a/packages/manager/.changeset/pr-10582-changed-1718310753730.md b/packages/manager/.changeset/pr-10582-changed-1718310753730.md new file mode 100644 index 00000000000..22d60d40356 --- /dev/null +++ b/packages/manager/.changeset/pr-10582-changed-1718310753730.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Link Cloud Manager README to new documentation pages ([#10582](https://github.com/linode/manager/pull/10582)) From 4ac62dbf0a2c0b4365e69ba1f4ce9da30e878efb Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 17 Jun 2024 13:38:35 -0400 Subject: [PATCH 096/163] feat: [M3-8229] - Volume & Images search and filtering (#10570) * Initial commit: search implementation * Cleaner logic * improve code and expand to images * e2e coverage * cleanup * type fixes post rebase * feedback @bnussman-akamai * Added changeset: Volume & Images landing pages search and filtering * feedback @bnussman-akamai * more changes from feedback * cleanup * fix empty state * moar cleanup * moar cleanup * code readability --- .../pr-10570-added-1718308378096.md | 5 + .../e2e/core/images/search-images.spec.ts | 79 ++++++++++++++ .../e2e/core/volumes/search-volumes.spec.ts | 62 +++++++++++ .../cypress/support/intercepts/linodes.ts | 17 ++- .../src/features/Images/ImagesLanding.tsx | 101 +++++++++++++++--- .../src/features/Volumes/VolumesLanding.tsx | 64 ++++++++++- .../src/store/selectors/getSearchEntities.ts | 31 +++--- 7 files changed, 321 insertions(+), 38 deletions(-) create mode 100644 packages/manager/.changeset/pr-10570-added-1718308378096.md create mode 100644 packages/manager/cypress/e2e/core/images/search-images.spec.ts create mode 100644 packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts diff --git a/packages/manager/.changeset/pr-10570-added-1718308378096.md b/packages/manager/.changeset/pr-10570-added-1718308378096.md new file mode 100644 index 00000000000..d8077d9974b --- /dev/null +++ b/packages/manager/.changeset/pr-10570-added-1718308378096.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Volume & Images landing pages search and filtering ([#10570](https://github.com/linode/manager/pull/10570)) diff --git a/packages/manager/cypress/e2e/core/images/search-images.spec.ts b/packages/manager/cypress/e2e/core/images/search-images.spec.ts new file mode 100644 index 00000000000..9620a2312e7 --- /dev/null +++ b/packages/manager/cypress/e2e/core/images/search-images.spec.ts @@ -0,0 +1,79 @@ +import { createImage } from '@linode/api-v4/lib/images'; +import { createTestLinode } from 'support/util/linodes'; +import { ui } from 'support/ui'; + +import { authenticate } from 'support/api/authentication'; +import { randomLabel } from 'support/util/random'; +import { cleanUp } from 'support/util/cleanup'; +import type { Image, Linode } from '@linode/api-v4'; +import { interceptGetLinodeDisks } from 'support/intercepts/linodes'; + +authenticate(); +describe('Search Images', () => { + before(() => { + cleanUp(['linodes', 'images']); + }); + + /* + * - Confirm that images are API searchable and filtered in the UI. + */ + it('creates two images and make sure they show up in the table and are searchable', () => { + cy.defer( + () => + createTestLinode( + { image: 'linode/debian10', region: 'us-east' }, + { waitForDisks: true } + ), + 'create linode' + ).then((linode: Linode) => { + interceptGetLinodeDisks(linode.id).as('getLinodeDisks'); + + cy.visitWithLogin(`/linodes/${linode.id}/storage`); + cy.wait('@getLinodeDisks').then((xhr) => { + const disks = xhr.response?.body.data; + const disk_id = disks[0].id; + + const createTwoImages = async (): Promise<[Image, Image]> => { + return Promise.all([ + createImage({ + disk_id, + label: randomLabel(), + }), + createImage({ + disk_id, + label: randomLabel(), + }), + ]); + }; + + cy.defer(() => createTwoImages(), 'creating images').then( + ([image1, image2]) => { + cy.visitWithLogin('/images'); + + // Confirm that both images are listed on the landing page. + cy.contains(image1.label).should('be.visible'); + cy.contains(image2.label).should('be.visible'); + + // Search for the first image by label, confirm it's the only one shown. + cy.findByPlaceholderText('Search Images').type(image1.label); + expect(cy.contains(image1.label).should('be.visible')); + expect(cy.contains(image2.label).should('not.exist')); + + // Clear search, confirm both images are shown. + cy.findByTestId('clear-images-search').click(); + cy.contains(image1.label).should('be.visible'); + cy.contains(image2.label).should('be.visible'); + + // Use the main search bar to search and filter images + cy.get('[id="main-search"').type(image2.label); + ui.autocompletePopper.findByTitle(image2.label).click(); + + // Confirm that only the second image is shown. + cy.contains(image1.label).should('not.exist'); + cy.contains(image2.label).should('be.visible'); + } + ); + }); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts new file mode 100644 index 00000000000..e6fc05b38b0 --- /dev/null +++ b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts @@ -0,0 +1,62 @@ +import { createVolume } from '@linode/api-v4/lib/volumes'; +import { Volume } from '@linode/api-v4'; +import { ui } from 'support/ui'; + +import { authenticate } from 'support/api/authentication'; +import { randomLabel } from 'support/util/random'; +import { cleanUp } from 'support/util/cleanup'; + +authenticate(); +describe('Search Volumes', () => { + before(() => { + cleanUp(['volumes']); + }); + + /* + * - Confirm that volumes are API searchable and filtered in the UI. + */ + it('creates two volumes and make sure they show up in the table and are searchable', () => { + const createTwoVolumes = async (): Promise<[Volume, Volume]> => { + return Promise.all([ + createVolume({ + label: randomLabel(), + region: 'us-east', + size: 10, + }), + createVolume({ + label: randomLabel(), + region: 'us-east', + size: 10, + }), + ]); + }; + + cy.defer(() => createTwoVolumes(), 'creating volumes').then( + ([volume1, volume2]) => { + cy.visitWithLogin('/volumes'); + + // Confirm that both volumes are listed on the landing page. + cy.findByText(volume1.label).should('be.visible'); + cy.findByText(volume2.label).should('be.visible'); + + // Search for the first volume by label, confirm it's the only one shown. + cy.findByPlaceholderText('Search Volumes').type(volume1.label); + expect(cy.findByText(volume1.label).should('be.visible')); + expect(cy.findByText(volume2.label).should('not.exist')); + + // Clear search, confirm both volumes are shown. + cy.findByTestId('clear-volumes-search').click(); + cy.findByText(volume1.label).should('be.visible'); + cy.findByText(volume2.label).should('be.visible'); + + // Use the main search bar to search and filter volumes + cy.get('[id="main-search"').type(volume2.label); + ui.autocompletePopper.findByTitle(volume2.label).click(); + + // Confirm that only the second volume is shown. + cy.findByText(volume1.label).should('not.exist'); + cy.findByText(volume2.label).should('be.visible'); + } + ); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index 932e1bc0bec..2a4a898068c 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -2,12 +2,12 @@ * @file Cypress intercepts and mocks for Cloud Manager Linode operations. */ +import { makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; -import type { Disk, Linode, LinodeType, Kernel, Volume } from '@linode/api-v4'; -import { makeErrorResponse } from 'support/util/errors'; +import type { Disk, Kernel, Linode, LinodeType, Volume } from '@linode/api-v4'; /** * Intercepts POST request to create a Linode. @@ -210,6 +210,19 @@ export const mockRebootLinodeIntoRescueModeError = ( ); }; +/** + * Intercepts GET request to retrieve a Linode's Disks + * + * @param linodeId - ID of Linode for intercepted request. + * + * @returns Cypress chainable. + */ +export const interceptGetLinodeDisks = ( + linodeId: number +): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher(`linode/instances/${linodeId}/disks*`)); +}; + /** * Intercepts GET request to retrieve a Linode's Disks and mocks response. * diff --git a/packages/manager/src/features/Images/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding.tsx index 2e27506b3bf..37a58e4152a 100644 --- a/packages/manager/src/features/Images/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding.tsx @@ -1,10 +1,9 @@ -import { Image, ImageStatus } from '@linode/api-v4'; -import { APIError } from '@linode/api-v4/lib/types'; -import { Theme } from '@mui/material/styles'; +import CloseIcon from '@mui/icons-material/Close'; import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; +import { useHistory, useLocation } from 'react-router-dom'; +import { debounce } from 'throttle-debounce'; import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -13,6 +12,8 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Hidden } from 'src/components/Hidden'; +import { IconButton } from 'src/components/IconButton'; +import { InputAdornment } from 'src/components/InputAdornment'; import { LandingHeader } from 'src/components/LandingHeader'; import { Notice } from 'src/components/Notice/Notice'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -23,7 +24,9 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; +import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; @@ -41,11 +44,17 @@ import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { EditImageDrawer } from './EditImageDrawer'; import ImageRow from './ImageRow'; -import { Handlers as ImageHandlers } from './ImagesActionMenu'; import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; import { RebuildImageDrawer } from './RebuildImageDrawer'; import { getEventsForImages } from './utils'; +import type { Handlers as ImageHandlers } from './ImagesActionMenu'; +import type { Image, ImageStatus } from '@linode/api-v4'; +import type { APIError } from '@linode/api-v4/lib/types'; +import type { Theme } from '@mui/material/styles'; + +const searchQueryKey = 'query'; + const useStyles = makeStyles()((theme: Theme) => ({ imageTable: { marginBottom: theme.spacing(3), @@ -81,6 +90,9 @@ export const ImagesLanding = () => { const { classes } = useStyles(); const history = useHistory(); const { enqueueSnackbar } = useSnackbar(); + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); + const imageLabelFromParam = queryParams.get(searchQueryKey) ?? ''; const queryClient = useQueryClient(); @@ -104,9 +116,14 @@ export const ImagesLanding = () => { ['+order_by']: manualImagesOrderBy, }; + if (imageLabelFromParam) { + manualImagesFilter['label'] = { '+contains': imageLabelFromParam }; + } + const { data: manualImages, error: manualImagesError, + isFetching: manualImagesIsFetching, isLoading: manualImagesLoading, } = useImagesQuery( { @@ -144,9 +161,14 @@ export const ImagesLanding = () => { ['+order_by']: automaticImagesOrderBy, }; + if (imageLabelFromParam) { + automaticImagesFilter['label'] = { '+contains': imageLabelFromParam }; + } + const { data: automaticImages, error: automaticImagesError, + isFetching: automaticImagesIsFetching, isLoading: automaticImagesLoading, } = useImagesQuery( { @@ -310,6 +332,17 @@ export const ImagesLanding = () => { ); }; + const resetSearch = () => { + queryParams.delete(searchQueryKey); + history.push({ search: queryParams.toString() }); + }; + + const onSearch = (e: React.ChangeEvent) => { + queryParams.delete('page'); + queryParams.set(searchQueryKey, e.target.value); + history.push({ search: queryParams.toString() }); + }; + const handlers: ImageHandlers = { onCancelFailed: onCancelFailedClick, onDelete: openDialog, @@ -350,7 +383,11 @@ export const ImagesLanding = () => { } /** Empty States */ - if (!manualImages.data.length && !automaticImages.data.length) { + if ( + !manualImages.data.length && + !automaticImages.data.length && + !imageLabelFromParam + ) { return renderEmpty(); } @@ -362,6 +399,8 @@ export const ImagesLanding = () => { ); + const isFetching = manualImagesIsFetching || automaticImagesIsFetching; + return ( @@ -371,6 +410,32 @@ export const ImagesLanding = () => { onButtonClick={() => history.push('/images/create')} title="Images" /> + + {isFetching && } + + + + + + ), + }} + onChange={debounce(400, (e) => { + onSearch(e); + })} + hideLabel + label="Search" + placeholder="Search Images" + sx={{ mb: 2 }} + value={imageLabelFromParam} + />
    Custom Images @@ -483,16 +548,20 @@ export const ImagesLanding = () => { - {automaticImages.data.length > 0 - ? automaticImages.data.map((automaticImage) => ( - - )) - : noAutomaticImages} + {isFetching ? ( + + ) : automaticImages.data.length > 0 ? ( + automaticImages.data.map((automaticImage) => ( + + )) + ) : ( + noAutomaticImages + )} { const history = useHistory(); - const location = useLocation<{ volume: Volume | undefined }>(); - const pagination = usePagination(1, preferenceKey); + const queryParams = new URLSearchParams(location.search); + const volumeLabelFromParam = queryParams.get(searchQueryKey) ?? ''; const { handleOrderChange, order, orderBy } = useOrder( { @@ -52,14 +59,17 @@ export const VolumesLanding = () => { ['+order_by']: orderBy, }; - const { data: volumes, error, isLoading } = useVolumesQuery( + if (volumeLabelFromParam) { + filter['label'] = { '+contains': volumeLabelFromParam }; + } + + const { data: volumes, error, isFetching, isLoading } = useVolumesQuery( { page: pagination.page, page_size: pagination.pageSize, }, filter ); - const [selectedVolumeId, setSelectedVolumeId] = React.useState(); const [isDetailsDrawerOpen, setIsDetailsDrawerOpen] = React.useState( Boolean(location.state?.volume) @@ -114,6 +124,17 @@ export const VolumesLanding = () => { setIsUpgradeDialogOpen(true); }; + const resetSearch = () => { + queryParams.delete(searchQueryKey); + history.push({ search: queryParams.toString() }); + }; + + const onSearch = (e: React.ChangeEvent) => { + queryParams.delete('page'); + queryParams.set(searchQueryKey, e.target.value); + history.push({ search: queryParams.toString() }); + }; + if (isLoading) { return ; } @@ -128,7 +149,7 @@ export const VolumesLanding = () => { ); } - if (volumes?.results === 0) { + if (volumes?.results === 0 && !volumeLabelFromParam) { return ; } @@ -136,11 +157,41 @@ export const VolumesLanding = () => { <> history.push('/volumes/create')} title="Volumes" /> + + {isFetching && } + + + + + + ), + }} + onChange={debounce(400, (e) => { + onSearch(e); + })} + hideLabel + label="Search" + placeholder="Search Volumes" + sx={{ mb: 2 }} + value={volumeLabelFromParam} + /> @@ -174,6 +225,9 @@ export const VolumesLanding = () => { + {volumes?.data.length === 0 && ( + + )} {volumes?.data.map((volume) => ( { const { ipv4, ipv6 } = linode; return ipv4.concat([ipv6 || '']); @@ -65,7 +67,7 @@ export const volumeToSearchableItem = (volume: Volume): SearchableItem => ({ created: volume.created, description: volume.size + ' GB', icon: 'volume', - path: `/volumes/${volume.id}`, + path: `/volumes?query=${volume.label}`, region: volume.region, tags: volume.tags, }, @@ -83,10 +85,9 @@ export const imageToSearchableItem = (image: Image): SearchableItem => ({ data: { created: image.created, description: image.description || '', - /* TODO: Update this with the Images icon! */ - icon: 'volume', + icon: 'image', /* TODO: Choose a real location for this to link to */ - path: `/images`, + path: `/images?query=${image.label}`, tags: [], }, entityType: 'image', From ca190997040ccb60cf18784d952bb7b944c72635 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Mon, 17 Jun 2024 18:26:53 -0400 Subject: [PATCH 097/163] test: Resolve Linode/Firewall E2E flake by using alternative security method (#10581) * Work around Linode/Firewall flake by using alternative security method * Added changeset: Fix Linode/Firewall related E2E test flake --- .../pr-10581-tests-1718308219716.md | 5 ++ .../e2e/core/linodes/clone-linode.spec.ts | 16 +++-- .../e2e/core/linodes/linode-config.spec.ts | 14 +++- .../e2e/core/linodes/rescue-linode.spec.ts | 68 ++++++++++--------- .../e2e/core/linodes/resize-linode.spec.ts | 35 ++++++++-- 5 files changed, 96 insertions(+), 42 deletions(-) create mode 100644 packages/manager/.changeset/pr-10581-tests-1718308219716.md diff --git a/packages/manager/.changeset/pr-10581-tests-1718308219716.md b/packages/manager/.changeset/pr-10581-tests-1718308219716.md new file mode 100644 index 00000000000..96c0f6d70db --- /dev/null +++ b/packages/manager/.changeset/pr-10581-tests-1718308219716.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix Linode/Firewall related E2E test flake ([#10581](https://github.com/linode/manager/pull/10581)) diff --git a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts index 5a664581264..29273aab7f3 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -33,6 +33,9 @@ const getLinodeCloneUrl = (linode: Linode): string => { return `/linodes/create?linodeID=${linode.id}${regionQuery}&type=Clone+Linode${typeQuery}`; }; +/* Timeout after 3 minutes while waiting for clone. */ +const CLONE_TIMEOUT = 180_000; + authenticate(); describe('clone linode', () => { before(() => { @@ -48,15 +51,19 @@ describe('clone linode', () => { const linodePayload = createLinodeRequestFactory.build({ label: randomLabel(), region: linodeRegion.id, - // Specifying no image allows the Linode to provision and clone faster. - image: undefined, booted: false, type: 'g6-nanode-1', }); const newLinodeLabel = `${linodePayload.label}-clone`; - cy.defer(() => createTestLinode(linodePayload)).then((linode: Linode) => { + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to interact with it shortly after booting up when the + // Linode is attached to a Cloud Firewall. + cy.defer(() => + createTestLinode(linodePayload, { securityMethod: 'vlan_no_internet' }) + ).then((linode: Linode) => { const linodeRegion = getRegionById(linodePayload.region!); interceptCloneLinode(linode.id).as('cloneLinode'); @@ -101,7 +108,8 @@ describe('clone linode', () => { ui.toast.assertMessage(`Your Linode ${newLinodeLabel} is being created.`); ui.toast.assertMessage( - `Linode ${linode.label} successfully cloned to ${newLinodeLabel}.` + `Linode ${linode.label} successfully cloned to ${newLinodeLabel}.`, + { timeout: CLONE_TIMEOUT } ); }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts index 38f4ceea087..e95ca4252d7 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -291,10 +291,20 @@ describe('Linode Config management', () => { */ it('Clones a config', () => { // Create clone source and destination Linodes. + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to interact with it shortly after booting up when the + // Linode is attached to a Cloud Firewall. const createCloneTestLinodes = async () => { return Promise.all([ - createTestLinode({ booted: true }, { waitForBoot: true }), - createTestLinode({ booted: true }), + createTestLinode( + { booted: true }, + { securityMethod: 'vlan_no_internet', waitForBoot: true } + ), + createTestLinode( + { booted: true }, + { securityMethod: 'vlan_no_internet' } + ), ]); }; diff --git a/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts index ee9a741e274..533eac2535f 100644 --- a/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts @@ -43,44 +43,50 @@ describe('Rescue Linodes', () => { region: chooseRegion().id, }); - cy.defer(() => createTestLinode(linodePayload), 'creating Linode').then( - (linode: Linode) => { - interceptGetLinodeDetails(linode.id).as('getLinode'); - interceptRebootLinodeIntoRescueMode(linode.id).as( - 'rebootLinodeRescueMode' - ); + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to interact with it shortly after booting up when the + // Linode is attached to a Cloud Firewall. + cy.defer( + () => + createTestLinode(linodePayload, { securityMethod: 'vlan_no_internet' }), + 'creating Linode' + ).then((linode: Linode) => { + interceptGetLinodeDetails(linode.id).as('getLinode'); + interceptRebootLinodeIntoRescueMode(linode.id).as( + 'rebootLinodeRescueMode' + ); - const rescueUrl = `/linodes/${linode.id}`; - cy.visitWithLogin(rescueUrl); - cy.wait('@getLinode'); + const rescueUrl = `/linodes/${linode.id}`; + cy.visitWithLogin(rescueUrl); + cy.wait('@getLinode'); - // Wait for Linode to boot. - cy.findByText('RUNNING').should('be.visible'); + // Wait for Linode to boot. + cy.findByText('RUNNING').should('be.visible'); - // Open rescue dialog using action menu.. - ui.actionMenu - .findByTitle(`Action menu for Linode ${linode.label}`) - .should('be.visible') - .click(); + // Open rescue dialog using action menu.. + ui.actionMenu + .findByTitle(`Action menu for Linode ${linode.label}`) + .should('be.visible') + .click(); - ui.actionMenuItem.findByTitle('Rescue').should('be.visible').click(); + ui.actionMenuItem.findByTitle('Rescue').should('be.visible').click(); - ui.dialog - .findByTitle(`Rescue Linode ${linode.label}`) - .should('be.visible') - .within(() => { - rebootInRescueMode(); - }); + ui.dialog + .findByTitle(`Rescue Linode ${linode.label}`) + .should('be.visible') + .within(() => { + rebootInRescueMode(); + }); - // Check intercepted response and make sure UI responded correctly. - cy.wait('@rebootLinodeRescueMode') - .its('response.statusCode') - .should('eq', 200); + // Check intercepted response and make sure UI responded correctly. + cy.wait('@rebootLinodeRescueMode') + .its('response.statusCode') + .should('eq', 200); - ui.toast.assertMessage('Linode rescue started.'); - cy.findByText('REBOOTING').should('be.visible'); - } - ); + ui.toast.assertMessage('Linode rescue started.'); + cy.findByText('REBOOTING').should('be.visible'); + }); }); /* diff --git a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts index 8dc827d6296..0d003ddd864 100644 --- a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts @@ -15,7 +15,13 @@ describe('resize linode', () => { it('resizes a linode by increasing size: warm migration', () => { mockGetFeatureFlagClientstream().as('getClientStream'); - cy.defer(() => createTestLinode({ booted: true })).then((linode) => { + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to interact with it shortly after booting up when the + // Linode is attached to a Cloud Firewall. + cy.defer(() => + createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) + ).then((linode) => { interceptLinodeResize(linode.id).as('linodeResize'); cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); cy.findByText('Shared CPU').click({ scrollBehavior: false }); @@ -35,7 +41,13 @@ describe('resize linode', () => { it('resizes a linode by increasing size: cold migration', () => { mockGetFeatureFlagClientstream().as('getClientStream'); - cy.defer(() => createTestLinode({ booted: true })).then((linode) => { + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to interact with it shortly after booting up when the + // Linode is attached to a Cloud Firewall. + cy.defer(() => + createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) + ).then((linode) => { interceptLinodeResize(linode.id).as('linodeResize'); cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); cy.findByText('Shared CPU').click({ scrollBehavior: false }); @@ -56,7 +68,13 @@ describe('resize linode', () => { it('resizes a linode by increasing size when offline: cold migration', () => { mockGetFeatureFlagClientstream().as('getClientStream'); - cy.defer(() => createTestLinode({ booted: true })).then((linode) => { + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to interact with it shortly after booting up when the + // Linode is attached to a Cloud Firewall. + cy.defer(() => + createTestLinode({ booted: true }, { securityMethod: 'vlan_no_internet' }) + ).then((linode) => { cy.visitWithLogin(`/linodes/${linode.id}`); // Turn off the linode to resize the disk @@ -97,9 +115,16 @@ describe('resize linode', () => { }); }); - it.only('resizes a linode by decreasing size', () => { + it('resizes a linode by decreasing size', () => { + // Use `vlan_no_internet` security method. + // This works around an issue where the Linode API responds with a 400 + // when attempting to interact with it shortly after booting up when the + // Linode is attached to a Cloud Firewall. cy.defer(() => - createTestLinode({ booted: true, type: 'g6-standard-2' }) + createTestLinode( + { booted: true, type: 'g6-standard-2' }, + { securityMethod: 'vlan_no_internet' } + ) ).then((linode) => { const diskName = 'Debian 11 Disk'; const size = '50000'; // 50 GB From 1f9814f4640e27086e1b7c747d8695847a257e15 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 18 Jun 2024 11:13:44 -0400 Subject: [PATCH 098/163] upcoming: [M3-8265] - Linode Create v2 - Add Marketplace Searching / Filtering (#10586) * implement searching / filtering * Added changeset: Linode Create v2 - Add Marketplace Searching / Filtering * allow searching by query and category at the same time * clean up search logic --------- Co-authored-by: Banks Nussman --- ...r-10586-upcoming-features-1718384912084.md | 5 + .../manager/src/factories/stackscripts.ts | 2 +- .../Tabs/Marketplace/AppSelect.tsx | 16 +++- .../Tabs/Marketplace/AppsList.tsx | 26 ++++-- .../Tabs/Marketplace/utilities.test.ts | 91 +++++++++++++++++++ .../Tabs/Marketplace/utilities.ts | 86 ++++++++++++++++++ 6 files changed, 217 insertions(+), 9 deletions(-) create mode 100644 packages/manager/.changeset/pr-10586-upcoming-features-1718384912084.md create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.test.ts diff --git a/packages/manager/.changeset/pr-10586-upcoming-features-1718384912084.md b/packages/manager/.changeset/pr-10586-upcoming-features-1718384912084.md new file mode 100644 index 00000000000..b6867f3165a --- /dev/null +++ b/packages/manager/.changeset/pr-10586-upcoming-features-1718384912084.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + + Linode Create v2 - Add Marketplace Searching / Filtering ([#10586](https://github.com/linode/manager/pull/10586)) diff --git a/packages/manager/src/factories/stackscripts.ts b/packages/manager/src/factories/stackscripts.ts index 398ac04a5ce..9406658d0b8 100644 --- a/packages/manager/src/factories/stackscripts.ts +++ b/packages/manager/src/factories/stackscripts.ts @@ -29,7 +29,7 @@ export const stackScriptFactory = Factory.Sync.makeFactory({ export const oneClickAppFactory = Factory.Sync.makeFactory({ alt_description: 'A test app', alt_name: 'Test App', - categories: ['App Creators'], + categories: ['Databases'], colors: { end: '#000000', start: '#000000', diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx index 71a159611dc..a804f3e21ff 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Box } from 'src/components/Box'; @@ -11,6 +11,8 @@ import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; import { AppsList } from './AppsList'; import { categoryOptions } from './utilities'; +import type { AppCategory } from 'src/features/OneClickApps/types'; + interface Props { /** * Opens the Marketplace App details drawer for the given app @@ -23,6 +25,9 @@ export const AppSelect = (props: Props) => { const { isLoading } = useMarketplaceAppsQuery(true); + const [query, setQuery] = useState(''); + const [category, setCategory] = useState(); + return ( @@ -37,7 +42,9 @@ export const AppSelect = (props: Props) => { label="Search marketplace" loading={isLoading} noMarginTop + onSearch={setQuery} placeholder="Search for app name" + value={query} /> { }} disabled={isLoading} label="Select category" + onChange={(e, value) => setCategory(value?.label)} options={categoryOptions} placeholder="Select category" /> - + diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx index 839e13bc267..a3e1d720328 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppsList.tsx @@ -12,25 +12,33 @@ import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; import { getDefaultUDFData } from '../StackScripts/UserDefinedFields/utilities'; import { AppSection } from './AppSection'; import { AppSelectionCard } from './AppSelectionCard'; -import { getAppSections } from './utilities'; +import { getAppSections, getFilteredApps } from './utilities'; import type { LinodeCreateFormValues } from '../../utilities'; import type { StackScript } from '@linode/api-v4'; +import type { AppCategory } from 'src/features/OneClickApps/types'; interface Props { + /** + * The selected category to filter by + */ + category: AppCategory | undefined; /** * Opens the Marketplace App details drawer for the given app */ onOpenDetailsDrawer: (stackscriptId: number) => void; + /** + * The search query + */ + query: string; } -export const AppsList = ({ onOpenDetailsDrawer }: Props) => { +export const AppsList = (props: Props) => { + const { category, onOpenDetailsDrawer, query } = props; const { data: stackscripts, error, isLoading } = useMarketplaceAppsQuery( true ); - const filter = null; - const { setValue } = useFormContext(); const { field } = useController({ name: 'stackscript_id', @@ -62,10 +70,16 @@ export const AppsList = ({ onOpenDetailsDrawer }: Props) => { return ; } - if (filter) { + if (category || query) { + const filteredStackScripts = getFilteredApps({ + category, + query, + stackscripts, + }); + return ( - {stackscripts?.map((stackscript) => ( + {filteredStackScripts?.map((stackscript) => ( { + it('should not perform any filtering if the search is empty', () => { + const result = getFilteredApps({ + category: undefined, + query: '', + stackscripts, + }); + + expect(result).toStrictEqual(stackscripts); + }); + + it('should allow a simple filter on label', () => { + const result = getFilteredApps({ + category: undefined, + query: 'mysql', + stackscripts, + }); + + expect(result).toStrictEqual([mysql]); + }); + + it('should allow a filter on label and catergory', () => { + const result = getFilteredApps({ + category: undefined, + query: 'mysql, database', + stackscripts, + }); + + expect(result).toStrictEqual([mysql]); + }); + + it('should allow filtering on StackScript id', () => { + const result = getFilteredApps({ + category: undefined, + query: '1037038', + stackscripts, + }); + + expect(result).toStrictEqual([vault]); + }); + + it('should allow filtering on alt description with many words', () => { + const result = getFilteredApps({ + category: undefined, + query: 'HashiCorp password', + stackscripts, + }); + + expect(result).toStrictEqual([vault]); + }); + + it('should filter if a category is selected in the category dropdown', () => { + const result = getFilteredApps({ + category: 'Databases', + query: '', + stackscripts, + }); + + expect(result).toStrictEqual([mysql]); + }); + + it('should allow searching by both a query and a category', () => { + const result = getFilteredApps({ + category: 'Databases', + query: 'My', + stackscripts, + }); + + expect(result).toStrictEqual([mysql]); + }); + + it('should return no matches if there are no results when searching by both query and category', () => { + const result = getFilteredApps({ + category: 'Databases', + query: 'HashiCorp', + stackscripts, + }); + + expect(result).toStrictEqual([]); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.ts index 3fb682fee07..c7dc8eababd 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/utilities.ts @@ -1,6 +1,7 @@ import { oneClickApps } from 'src/features/OneClickApps/oneClickAppsv2'; import type { StackScript } from '@linode/api-v4'; +import type { AppCategory } from 'src/features/OneClickApps/types'; /** * Get all categories from our marketplace apps list so @@ -52,3 +53,88 @@ export const getAppSections = (stackscripts: StackScript[]) => { }, ]; }; + +interface FilterdAppsOptions { + category: AppCategory | undefined; + query: string; + stackscripts: StackScript[]; +} + +/** + * Performs the client side filtering Marketplace Apps on the Linode Create flow + * + * Currently, we only allow users to search OR filter by category in the UI. + * We don't allow both at the same time. If we want to change that, this function + * will need to be modified. + * + * @returns Stackscripts that have been filtered based on the options passed + */ +export const getFilteredApps = (options: FilterdAppsOptions) => { + const { category, query, stackscripts } = options; + + return stackscripts.filter((stackscript) => { + if (query && category) { + return ( + getDoesStackScriptMatchQuery(query, stackscript) && + getDoesStackScriptMatchCategory(category, stackscript) + ); + } + + if (query) { + return getDoesStackScriptMatchQuery(query, stackscript); + } + + if (category) { + return getDoesStackScriptMatchCategory(category, stackscript); + } + + return true; + }); +}; + +/** + * Compares a StackScript's details to a given text search query + * + * @param query the current search query + * @param stackscript the StackScript to compare aginst + * @returns true if the StackScript matches the given query + */ +const getDoesStackScriptMatchQuery = ( + query: string, + stackscript: StackScript +) => { + const appDetails = oneClickApps[stackscript.id]; + + const queryWords = query + .replace(/[,.-]/g, '') + .trim() + .toLocaleLowerCase() + .split(' '); + + const searchableAppFields = [ + String(stackscript.id), + stackscript.label, + appDetails.name, + appDetails.alt_name, + appDetails.alt_description, + ...appDetails.categories, + ]; + + return searchableAppFields.some((field) => + queryWords.some((queryWord) => field.toLowerCase().includes(queryWord)) + ); +}; + +/** + * Checks if the given StackScript has a category + * + * @param category The category to check for + * @param stackscript The StackScript to compare aginst + * @returns true if the given StackScript has the given category + */ +const getDoesStackScriptMatchCategory = ( + category: AppCategory, + stackscript: StackScript +) => { + return oneClickApps[stackscript.id].categories.includes(category); +}; From 7ee2cac2fea66ef4dc735147066f4ee438f8ed42 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 18 Jun 2024 13:56:58 -0400 Subject: [PATCH 099/163] fix: [M3-7646] CONTRIBUTING commit type list markup (#10587) * fix CONTRIBUTING commit type list markup * Added changeset: CONTRIBUTING doc page commit type list markup * feedback @mjac0bs --- docs/CONTRIBUTING.md | 17 +++++++++-------- .../.changeset/pr-10587-fixed-1718643059797.md | 5 +++++ 2 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 packages/manager/.changeset/pr-10587-fixed-1718643059797.md diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 1d3cf06f7aa..e4197539522 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -17,25 +17,26 @@ Feel free to open an issue to report a bug or request a feature. 5. Commit message format standard: `: [JIRA-ticket-number] - ` **Commit Types:** - `feat`: New feature for the user (not a part of the code, or ci, ...). - `fix`: Bugfix for the user (not a fix to build something, ...). - `change`: Modifying an existing visual UI instance. Such as a component or a feature. - `refactor`: Restructuring existing code without changing its external behavior or visual UI. Typically to improve readability, maintainability, and performance. - `test`: New tests or changes to existing tests. Does not change the production code. - `upcoming`: A new feature that is in progress, not visible to users yet, and usually behind a feature flag. + - `feat`: New feature for the user (not a part of the code, or ci, ...). + - `fix`: Bugfix for the user (not a fix to build something, ...). + - `change`: Modifying an existing visual UI instance. Such as a component or a feature. + - `refactor`: Restructuring existing code without changing its external behavior or visual UI. Typically to improve readability, maintainability, and performance. + - `test`: New tests or changes to existing tests. Does not change the production code. + - `upcoming`: A new feature that is in progress, not visible to users yet, and usually behind a feature flag. **Example:** `feat: [M3-1234] - Allow user to view their login history` 6. Open a pull request against `develop` and make sure the title follows the same format as the commit message. 7. If needed, create a changeset to populate our changelog - - If you don't have the Github CLI installed or need to update it (you need GH CLI 2.21.0 or greater), + - If you don't have the Github CLI installed or need to update it (you need GH CLI 2.21.0 or greater), - install it via `brew`: https://cli.github.com/manual/installation or upgrade with `brew upgrade gh` - Once installed, run `gh repo set-default` and pick `linode/manager` (only > 2.21.0) - You can also just create the changeset manually, in this case make sure to use the proper formatting for it. - Run `yarn changeset`from the root, choose the package to create a changeset for, and provide a description for the change. You can either have it committed automatically or do it manually if you need to edit it. - - A changeset is optional, it merely depends if it falls in one of the following categories: + - A changeset is optional, but should be included if the PR falls in one of the following categories:
    `Added`, `Fixed`, `Changed`, `Removed`, `Tech Stories`, `Tests`, `Upcoming Features` + - Select the changeset category that matches the commit type in your PR title. (Where this isn't a 1:1 match: generally, a `feat` commit type falls under an `Added` change and `refactor` falls under `Tech Stories`.) Two reviews from members of the Cloud Manager team are required before merge. After approval, all pull requests are squash merged. diff --git a/packages/manager/.changeset/pr-10587-fixed-1718643059797.md b/packages/manager/.changeset/pr-10587-fixed-1718643059797.md new file mode 100644 index 00000000000..1d10211f718 --- /dev/null +++ b/packages/manager/.changeset/pr-10587-fixed-1718643059797.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +CONTRIBUTING doc page commit type list markup ([#10587](https://github.com/linode/manager/pull/10587)) From 9939b41b4a055bc7ef568189ab43e571652f631e Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 18 Jun 2024 14:24:53 -0400 Subject: [PATCH 100/163] fix: React Query Events `seen` and other optimizations (#10588) * initial work * don't poll for `profile_update` events * fixes * update unit test * freeze constant, add comment, delete unused code * Added changeset: React Query Events `seen` behavior and other optimizations --------- Co-authored-by: Banks Nussman --- .../pr-10588-fixed-1718731923572.md | 5 ++ .../src/features/Events/EventsLanding.tsx | 2 +- .../manager/src/features/Events/constants.ts | 15 ++++- .../NotificationCenter/EventsV2.test.tsx | 34 ----------- .../features/NotificationCenter/EventsV2.tsx | 29 --------- .../NotificationData/RenderEventV2.tsx | 46 +++++++------- .../useEventNotificationsV2.tsx | 48 --------------- .../NotificationMenu/NotificationMenuV2.tsx | 61 +++++++++++-------- .../src/queries/events/event.helpers.test.ts | 16 ++++- .../src/queries/events/event.helpers.ts | 17 ++++-- packages/manager/src/queries/events/events.ts | 33 +++++++--- 11 files changed, 127 insertions(+), 179 deletions(-) create mode 100644 packages/manager/.changeset/pr-10588-fixed-1718731923572.md delete mode 100644 packages/manager/src/features/NotificationCenter/EventsV2.test.tsx delete mode 100644 packages/manager/src/features/NotificationCenter/EventsV2.tsx delete mode 100644 packages/manager/src/features/NotificationCenter/NotificationData/useEventNotificationsV2.tsx diff --git a/packages/manager/.changeset/pr-10588-fixed-1718731923572.md b/packages/manager/.changeset/pr-10588-fixed-1718731923572.md new file mode 100644 index 00000000000..e12343bfa68 --- /dev/null +++ b/packages/manager/.changeset/pr-10588-fixed-1718731923572.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +React Query Events `seen` behavior and other optimizations ([#10588](https://github.com/linode/manager/pull/10588)) diff --git a/packages/manager/src/features/Events/EventsLanding.tsx b/packages/manager/src/features/Events/EventsLanding.tsx index 33bfba77ee5..0caf895222f 100644 --- a/packages/manager/src/features/Events/EventsLanding.tsx +++ b/packages/manager/src/features/Events/EventsLanding.tsx @@ -32,7 +32,7 @@ export const EventsLanding = (props: Props) => { const { emptyMessage, entityId } = props; const flags = useFlags(); - const filter = EVENTS_LIST_FILTER; + const filter = { ...EVENTS_LIST_FILTER }; if (entityId) { filter['entity.id'] = entityId; diff --git a/packages/manager/src/features/Events/constants.ts b/packages/manager/src/features/Events/constants.ts index b1ed2afe60d..ad07694713c 100644 --- a/packages/manager/src/features/Events/constants.ts +++ b/packages/manager/src/features/Events/constants.ts @@ -1,5 +1,5 @@ // TODO eventMessagesV2: delete when flag is removed -import type { Event, Filter } from '@linode/api-v4'; +import type { Event } from '@linode/api-v4'; export const EVENT_ACTIONS: Event['action'][] = [ 'account_settings_update', @@ -154,6 +154,15 @@ export const ACTIONS_TO_INCLUDE_AS_PROGRESS_EVENTS: Event['action'][] = [ 'database_resize', ]; -export const EVENTS_LIST_FILTER: Filter = { +/** + * This is our base filter for GETing /v4/account/events. + * + * We exclude `profile_update` events because they are generated + * often (by updating user preferences for example) and we don't + * need them. + * + * @readonly Do not modify this object + */ +export const EVENTS_LIST_FILTER = Object.freeze({ action: { '+neq': 'profile_update' }, -}; +}); diff --git a/packages/manager/src/features/NotificationCenter/EventsV2.test.tsx b/packages/manager/src/features/NotificationCenter/EventsV2.test.tsx deleted file mode 100644 index 5c5c198cbe0..00000000000 --- a/packages/manager/src/features/NotificationCenter/EventsV2.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from 'react'; - -import { eventFactory } from 'src/factories'; -import { renderWithTheme, resizeScreenSize } from 'src/utilities/testHelpers'; - -import { EventsV2 } from './EventsV2'; - -import type { NotificationItem } from './NotificationSection'; - -const events = eventFactory.buildList(20); -const eventNotifications: NotificationItem[] = events.map((event) => ({ - body: event.message!, - countInTotal: true, - eventId: event.id, - id: `event-${event.id}`, - showProgress: false, -})); - -describe('EventsV2', () => { - it('should render', () => { - resizeScreenSize(1600); - - const { getAllByTestId, getByText } = renderWithTheme( - - ); - - expect(getByText('Events')).toBeInTheDocument(); - expect(getByText('View all events')).toBeInTheDocument(); - expect(getAllByTestId('notification-item')).toHaveLength(20); - }); -}); diff --git a/packages/manager/src/features/NotificationCenter/EventsV2.tsx b/packages/manager/src/features/NotificationCenter/EventsV2.tsx deleted file mode 100644 index e750edf1096..00000000000 --- a/packages/manager/src/features/NotificationCenter/EventsV2.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import * as React from 'react'; - -import { NotificationSection } from './NotificationSection'; - -import type { NotificationItem } from './NotificationSection'; - -const NUM_EVENTS_DISPLAY = 20; - -interface EventsV2Props { - eventNotifications: NotificationItem[]; - onCloseNotificationCenter: () => void; -} - -export const EventsV2 = ({ - eventNotifications, - onCloseNotificationCenter, -}: EventsV2Props) => { - return ( - - ); -}; diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx index 64ce3bbdb81..ca3ccf71217 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/RenderEventV2.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { BarPercent } from 'src/components/BarPercent'; import { Box } from 'src/components/Box'; -import { Divider } from 'src/components/Divider'; import { Typography } from 'src/components/Typography'; import { formatProgressEvent, @@ -40,29 +39,26 @@ export const RenderEventV2 = React.memo((props: RenderEventProps) => { const { progressEventDisplay, showProgress } = formatProgressEvent(event); return ( - <> - - - - {message} - {showProgress && ( - - )} - - {progressEventDisplay} | {event.username ?? 'Linode'} - - - - - + + + + {message} + {showProgress && ( + + )} + + {progressEventDisplay} | {event.username ?? 'Linode'} + + + ); }); diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotificationsV2.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotificationsV2.tsx deleted file mode 100644 index 5cfeae26faa..00000000000 --- a/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotificationsV2.tsx +++ /dev/null @@ -1,48 +0,0 @@ -// TODO eventMessagesV2: Do we need to handle unwanted taxId events -import * as React from 'react'; - -import { EVENTS_LIST_FILTER } from 'src/features/Events/constants'; -import { formatProgressEvent } from 'src/features/Events/utils'; -import { useEventsInfiniteQuery } from 'src/queries/events/events'; - -import { notificationContext as _notificationContext } from '../NotificationContext'; -import { RenderEventV2 } from './RenderEventV2'; - -import type { NotificationItem } from '../NotificationSection'; -import type { Event } from '@linode/api-v4'; - -export const useEventNotificationsV2 = () => { - // `profile_update` is a noisy event - // Any change to user preferences will trigger this event, so we filter it out at the API level - const { events } = useEventsInfiniteQuery(EVENTS_LIST_FILTER); - const notificationContext = React.useContext(_notificationContext); - - const formattedEvents = events?.map((event) => { - const { showProgress } = formatProgressEvent(event); - - return formatEventForDisplay({ - event, - onClose: notificationContext.closeMenu, - showProgress, - }); - }); - - return formattedEvents?.filter((event) => Boolean(event.body)) ?? []; -}; - -interface FormattedEventForDisplay { - event: Event; - onClose: () => void; - showProgress: boolean; -} - -const formatEventForDisplay = ({ - event, - onClose, -}: FormattedEventForDisplay): NotificationItem => ({ - body: , - countInTotal: !event.seen, - eventId: event.id, - id: `event-${event.id}`, - showProgress: formatProgressEvent(event).showProgress, -}); diff --git a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.tsx b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.tsx index 07c3ad98e27..a83eb52ef4c 100644 --- a/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.tsx +++ b/packages/manager/src/features/TopMenu/NotificationMenu/NotificationMenuV2.tsx @@ -3,43 +3,50 @@ import { IconButton } from '@mui/material'; import Popover from '@mui/material/Popover'; import { styled } from '@mui/material/styles'; import * as React from 'react'; +import { useHistory } from 'react-router-dom'; import Bell from 'src/assets/icons/notification.svg'; +import { Box } from 'src/components/Box'; import { Chip } from 'src/components/Chip'; -import { EventsV2 } from 'src/features/NotificationCenter/EventsV2'; +import { Divider } from 'src/components/Divider'; +import { LinkButton } from 'src/components/LinkButton'; +import { Typography } from 'src/components/Typography'; import { notificationContext as _notificationContext, menuButtonId, } from 'src/features/NotificationCenter/NotificationContext'; -import { useEventNotificationsV2 } from 'src/features/NotificationCenter/NotificationData/useEventNotificationsV2'; +import { RenderEventV2 } from 'src/features/NotificationCenter/NotificationData/RenderEventV2'; import { useFormattedNotifications } from 'src/features/NotificationCenter/NotificationData/useFormattedNotifications'; import Notifications from 'src/features/NotificationCenter/Notifications'; import { useDismissibleNotifications } from 'src/hooks/useDismissibleNotifications'; import { usePrevious } from 'src/hooks/usePrevious'; import { useNotificationsQuery } from 'src/queries/account/notifications'; -import { useMarkEventsAsSeen } from 'src/queries/events/events'; +import { isInProgressEvent } from 'src/queries/events/event.helpers'; +import { + useEventsInfiniteQuery, + useMarkEventsAsSeen, +} from 'src/queries/events/events'; import { rotate360 } from 'src/styles/keyframes'; import { TopMenuTooltip, topMenuIconButtonSx } from '../TopMenuTooltip'; export const NotificationMenuV2 = () => { + const history = useHistory(); const { dismissNotifications } = useDismissibleNotifications(); const { data: notifications } = useNotificationsQuery(); const formattedNotifications = useFormattedNotifications(); - const eventNotifications = useEventNotificationsV2(); const notificationContext = React.useContext(_notificationContext); + + const { data, events } = useEventsInfiniteQuery(); const { mutateAsync: markEventsAsSeen } = useMarkEventsAsSeen(); const numNotifications = - eventNotifications.filter( - (notificationItem) => notificationItem.countInTotal - ).length + + (events?.filter((event) => !event.seen).length ?? 0) + formattedNotifications.filter( (notificationItem) => notificationItem.countInTotal ).length; - const showInProgressEventIcon = eventNotifications.some( - (notificationItem) => notificationItem.showProgress - ); + + const showInProgressEventIcon = events?.some(isInProgressEvent); const anchorRef = React.useRef(null); const prevOpen = usePrevious(notificationContext.menuOpen); @@ -59,19 +66,12 @@ export const NotificationMenuV2 = () => { React.useEffect(() => { if (prevOpen && !notificationContext.menuOpen) { // Dismiss seen notifications after the menu has closed. - if (eventNotifications.length > 0) { - markEventsAsSeen(eventNotifications[0].eventId); + if (events && events.length >= 1 && !events[0].seen) { + markEventsAsSeen(events[0].id); } dismissNotifications(notifications ?? [], { prefix: 'notificationMenu' }); } - }, [ - notificationContext.menuOpen, - dismissNotifications, - eventNotifications, - notifications, - prevOpen, - markEventsAsSeen, - ]); + }, [notificationContext.menuOpen]); const id = notificationContext.menuOpen ? 'notifications-popover' : undefined; @@ -130,10 +130,23 @@ export const NotificationMenuV2 = () => { open={notificationContext.menuOpen} > - notificationContext.closeMenu()} - /> + + + Events + { + history.push('/events'); + handleClose(); + }} + > + View all events + + + + {data?.pages[0].data.slice(0, 20).map((event) => ( + + ))} + ); diff --git a/packages/manager/src/queries/events/event.helpers.test.ts b/packages/manager/src/queries/events/event.helpers.test.ts index bb8a45b671c..8b156961ec6 100644 --- a/packages/manager/src/queries/events/event.helpers.test.ts +++ b/packages/manager/src/queries/events/event.helpers.test.ts @@ -161,7 +161,12 @@ describe('requestFilters', () => { it('generates a simple filter when pollIDs is empty', () => { const result = generatePollingFilter(timestamp, []); - expect(result).toEqual({ created: { '+gte': timestamp } }); + expect(result).toEqual({ + '+order': 'desc', + '+order_by': 'id', + action: { '+neq': 'profile_update' }, + created: { '+gte': timestamp }, + }); }); it('handles "in" IDs', () => { @@ -174,12 +179,18 @@ describe('requestFilters', () => { { id: 2 }, { id: 3 }, ], + '+order': 'desc', + '+order_by': 'id', + action: { '+neq': 'profile_update' }, }); }); it('handles "+neq" IDs', () => { const result = generatePollingFilter(timestamp, [], [1, 2, 3]); expect(result).toEqual({ + '+order': 'desc', + '+order_by': 'id', + action: { '+neq': 'profile_update' }, '+and': [ { created: { '+gte': timestamp } }, { id: { '+neq': 1 } }, @@ -192,6 +203,9 @@ describe('requestFilters', () => { it('handles "in" and "+neq" IDs together', () => { const result = generatePollingFilter(timestamp, [1, 2, 3], [4, 5, 6]); expect(result).toEqual({ + '+order': 'desc', + '+order_by': 'id', + action: { '+neq': 'profile_update' }, '+or': [ { '+and': [ diff --git a/packages/manager/src/queries/events/event.helpers.ts b/packages/manager/src/queries/events/event.helpers.ts index fe1e643d8eb..b82a64a2b50 100644 --- a/packages/manager/src/queries/events/event.helpers.ts +++ b/packages/manager/src/queries/events/event.helpers.ts @@ -1,4 +1,6 @@ -import { Event, EventAction, Filter } from '@linode/api-v4'; +import { EVENTS_LIST_FILTER } from 'src/features/Events/constants'; + +import type { Event, EventAction, Filter } from '@linode/api-v4'; export const isInProgressEvent = (event: Event) => { if (event.percent_complete === null) { @@ -103,8 +105,10 @@ export const generatePollingFilter = ( timestamp: string, inIds: number[] = [], neqIds: number[] = [] -) => { - let filter: Filter = { created: { '+gte': timestamp } }; +): Filter => { + let filter: Filter = { + created: { '+gte': timestamp }, + }; if (neqIds.length > 0) { filter = { @@ -118,7 +122,12 @@ export const generatePollingFilter = ( }; } - return filter; + return { + ...filter, + ...EVENTS_LIST_FILTER, + '+order': 'desc', + '+order_by': 'id', + }; }; /** diff --git a/packages/manager/src/queries/events/events.ts b/packages/manager/src/queries/events/events.ts index 5e8554cd756..62af8422b0e 100644 --- a/packages/manager/src/queries/events/events.ts +++ b/packages/manager/src/queries/events/events.ts @@ -1,17 +1,15 @@ import { getEvents, markEventSeen } from '@linode/api-v4'; -import { DateTime } from 'luxon'; -import { useRef } from 'react'; import { - InfiniteData, - QueryClient, - QueryKey, useInfiniteQuery, useMutation, useQuery, useQueryClient, } from '@tanstack/react-query'; +import { DateTime } from 'luxon'; +import { useRef } from 'react'; import { ISO_DATETIME_NO_TZ_FORMAT, POLLING_INTERVALS } from 'src/constants'; +import { EVENTS_LIST_FILTER } from 'src/features/Events/constants'; import { useEventHandlers } from 'src/hooks/useEventHandlers'; import { useToastNotifications } from 'src/hooks/useToastNotifications'; import { @@ -22,6 +20,11 @@ import { } from 'src/queries/events/event.helpers'; import type { APIError, Event, Filter, ResourcePage } from '@linode/api-v4'; +import type { + InfiniteData, + QueryClient, + QueryKey, +} from '@tanstack/react-query'; /** * Gets an infinitely scrollable list of all Events @@ -35,13 +38,18 @@ import type { APIError, Event, Filter, ResourcePage } from '@linode/api-v4'; * We are doing this as opposed to page based pagination because we need an accurate way to get * the next set of events when the items returned by the server may have shifted. */ -export const useEventsInfiniteQuery = (filter?: Filter) => { +export const useEventsInfiniteQuery = (filter: Filter = EVENTS_LIST_FILTER) => { const query = useInfiniteQuery, APIError[]>( ['events', 'infinite', filter], ({ pageParam }) => getEvents( {}, - { ...filter, id: pageParam ? { '+lt': pageParam } : undefined } + { + ...filter, + '+order': 'desc', + '+order_by': 'id', + id: pageParam ? { '+lt': pageParam } : undefined, + } ), { cacheTime: Infinity, @@ -124,7 +132,7 @@ export const useEventsPoller = () => { const data = queryClient.getQueryData>>([ 'events', 'infinite', - undefined, + EVENTS_LIST_FILTER, ]); const events = data?.pages.reduce( (events, page) => [...events, ...page.data], @@ -199,8 +207,8 @@ export const useMarkEventsAsSeen = () => { (eventId) => markEventSeen(eventId), { onSuccess: (_, eventId) => { - queryClient.setQueryData>>( - ['events', 'infinite', undefined], + queryClient.setQueriesData>>( + ['events', 'infinite'], (prev) => { if (!prev) { return { @@ -311,6 +319,11 @@ export const updateEventsQuery = ( if (newEvents.length > 0) { // For all events, that remain, append them to the top of the events list prev.pages[0].data = [...newEvents, ...prev.pages[0].data]; + + // Update the `results` value for all pages so it is up to date + for (const page of prev.pages) { + page.results += newEvents.length; + } } return { From a95b715a721aae813d76644f071282d8baf0cc41 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 18 Jun 2024 16:28:40 -0400 Subject: [PATCH 101/163] fix: [M3-8269] - Accessibility: Add tabindex to TextTooltip (#10590) * Add tabindex to TextTooltip * Added changeset: Accessibility: Add tabindex to TextTooltip --- packages/manager/.changeset/pr-10590-fixed-1718722141069.md | 5 +++++ packages/manager/src/components/TextTooltip/TextTooltip.tsx | 1 + 2 files changed, 6 insertions(+) create mode 100644 packages/manager/.changeset/pr-10590-fixed-1718722141069.md diff --git a/packages/manager/.changeset/pr-10590-fixed-1718722141069.md b/packages/manager/.changeset/pr-10590-fixed-1718722141069.md new file mode 100644 index 00000000000..2fbb1319e47 --- /dev/null +++ b/packages/manager/.changeset/pr-10590-fixed-1718722141069.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Accessibility: Add tabindex to TextTooltip ([#10590](https://github.com/linode/manager/pull/10590)) diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.tsx index e480ea3f0c6..5ce4c7f1598 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.tsx @@ -69,6 +69,7 @@ export const TextTooltip = (props: TextTooltipProps) => { data-qa-tooltip={dataQaTooltip} enterTouchDelay={0} placement={placement ? placement : 'bottom'} + tabIndex={0} title={tooltipText} > From 1f2d6662f75df4389ef26d5017f8b70feaabea1b Mon Sep 17 00:00:00 2001 From: carrillo-erik <119514965+carrillo-erik@users.noreply.github.com> Date: Tue, 18 Jun 2024 13:58:33 -0700 Subject: [PATCH 102/163] change: [M3-7383] - Replace hardcoded prices for LKE HA with data from `lke/types` API endpoint (#10505) * change: [M3-7383] - Replace hardcoded LKE HA prices with API data * Add changesets * Update HA pricing logic to account for API errors * Revert file back to previous version * Show tooltip as part of radio button label during API error * Implement UX updates to handle error and loading states * Fix ha control plane tests * More changes from PR review * Fix tests (e2e and unit) and update regionId data type and PR feedback * Update highAvailability type and revert update to linode type values --- .../pr-10505-added-1716400052332.md | 5 + packages/api-v4/src/kubernetes/kubernetes.ts | 16 ++- .../pr-10505-changed-1716400104404.md | 5 + .../e2e/core/kubernetes/lke-create.spec.ts | 2 +- packages/manager/src/MainContent.tsx | 6 +- .../components/RegionSelect/RegionSelect.tsx | 4 +- .../RegionSelect/RegionSelect.types.ts | 5 +- .../SelectRegionPanel/SelectRegionPanel.tsx | 6 +- packages/manager/src/factories/types.ts | 37 +++++++ .../shared/CloudPulseRegionSelect.tsx | 3 +- .../CreateCluster/CreateCluster.tsx | 85 +++++++++------ .../CreateCluster/HAControlPlane.test.tsx | 24 +++-- .../CreateCluster/HAControlPlane.tsx | 49 +++++++-- .../CreateCluster/NodePoolPanel.tsx | 2 +- .../KubeCheckoutBar/KubeCheckoutBar.test.tsx | 47 ++++++-- .../KubeCheckoutBar/KubeCheckoutBar.tsx | 26 +++-- .../KubeCheckoutBar/NodePoolSummary.test.tsx | 2 +- .../KubeCheckoutBar/NodePoolSummary.tsx | 8 +- .../KubeClusterSpecs.tsx | 91 +++++++++++----- .../KubeSummaryPanel.tsx | 13 ++- .../KubernetesClusterDetail.tsx | 13 +-- .../UpgradeClusterDialog.tsx | 102 ++++++++++++------ .../KubernetesClusterDetail/index.tsx | 1 - .../manager/src/features/Kubernetes/index.tsx | 16 ++- .../src/features/Kubernetes/kubeUtils.ts | 7 +- .../Linodes/MigrateLinode/ConfigureForm.tsx | 2 +- .../Linodes/MigrateLinode/MigrateLinode.tsx | 12 +-- .../BucketLanding/BucketRegions.tsx | 2 +- .../VPCs/VPCLanding/VPCEditDrawer.tsx | 7 +- packages/manager/src/mocks/serverHandlers.ts | 9 ++ packages/manager/src/queries/kubernetes.ts | 48 ++++++--- .../manager/src/utilities/formatRegion.ts | 8 +- .../src/utilities/pricing/constants.ts | 7 +- .../utilities/pricing/dynamicPricing.test.ts | 23 ++++ .../src/utilities/pricing/kubernetes.test.tsx | 2 +- .../src/utilities/pricing/kubernetes.ts | 3 +- 36 files changed, 481 insertions(+), 217 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10505-added-1716400052332.md create mode 100644 packages/manager/.changeset/pr-10505-changed-1716400104404.md delete mode 100644 packages/manager/src/features/Kubernetes/KubernetesClusterDetail/index.tsx diff --git a/packages/api-v4/.changeset/pr-10505-added-1716400052332.md b/packages/api-v4/.changeset/pr-10505-added-1716400052332.md new file mode 100644 index 00000000000..c9a609f8b06 --- /dev/null +++ b/packages/api-v4/.changeset/pr-10505-added-1716400052332.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Added +--- + +New endpoint for LKE HA types used in pricing ([#10505](https://github.com/linode/manager/pull/10505)) diff --git a/packages/api-v4/src/kubernetes/kubernetes.ts b/packages/api-v4/src/kubernetes/kubernetes.ts index 8d62051c137..b80c011d4cf 100644 --- a/packages/api-v4/src/kubernetes/kubernetes.ts +++ b/packages/api-v4/src/kubernetes/kubernetes.ts @@ -7,8 +7,8 @@ import Request, { setURL, setXFilter, } from '../request'; -import { Filter, Params, ResourcePage as Page } from '../types'; -import { +import type { Filter, Params, ResourcePage as Page, PriceType } from '../types'; +import type { CreateKubeClusterPayload, KubeConfigResponse, KubernetesCluster, @@ -180,3 +180,15 @@ export const recycleClusterNodes = (clusterID: number) => setMethod('POST'), setURL(`${API_ROOT}/lke/clusters/${encodeURIComponent(clusterID)}/recycle`) ); + +/** + * getKubernetesTypes + * + * Returns a paginated list of available Kubernetes types; used for dynamic pricing. + */ +export const getKubernetesTypes = (params?: Params) => + Request>( + setURL(`${API_ROOT}/lke/types`), + setMethod('GET'), + setParams(params) + ); diff --git a/packages/manager/.changeset/pr-10505-changed-1716400104404.md b/packages/manager/.changeset/pr-10505-changed-1716400104404.md new file mode 100644 index 00000000000..e5c959f550c --- /dev/null +++ b/packages/manager/.changeset/pr-10505-changed-1716400104404.md @@ -0,0 +1,5 @@ +--- +'@linode/manager': Changed +--- + +Use dynamic HA pricing with `lke/types` endpoint ([#10505](https://github.com/linode/manager/pull/10505)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index 7ca34e9d429..fd495ecc0c0 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -266,7 +266,7 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { ui.regionSelect.find().type(`${dcSpecificPricingRegion.label}{enter}`); // Confirm that HA price updates dynamically once region selection is made. - cy.contains(/\(\$.*\/month\)/).should('be.visible'); + cy.contains(/\$.*\/month/).should('be.visible'); cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click(); diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index c74fd368483..c73a627e77d 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -130,7 +130,11 @@ const Domains = React.lazy(() => })) ); const Images = React.lazy(() => import('src/features/Images')); -const Kubernetes = React.lazy(() => import('src/features/Kubernetes')); +const Kubernetes = React.lazy(() => + import('src/features/Kubernetes').then((module) => ({ + default: module.Kubernetes, + })) +); const ObjectStorage = React.lazy(() => import('src/features/ObjectStorage')); const Profile = React.lazy(() => import('src/features/Profile/Profile')); const LoadBalancers = React.lazy(() => diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index 6c1fbef0d0c..533201a3fa3 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -70,7 +70,9 @@ export const RegionSelect = < regions, }); - const selectedRegion = regionOptions.find((r) => r.id === value) ?? null; + const selectedRegion = value + ? regionOptions.find((r) => r.id === value) + : null; const disabledRegions = regionOptions.reduce< Record diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts index d2dfbe23f96..83413b4bae0 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.types.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.types.ts @@ -1,11 +1,10 @@ -import React from 'react'; - import type { AccountAvailability, Capabilities, Region, RegionSite, } from '@linode/api-v4'; +import type React from 'react'; import type { EnhancedAutocompleteProps } from 'src/components/Autocomplete/Autocomplete'; export interface DisableRegionOption { @@ -49,7 +48,7 @@ export interface RegionSelectProps< /** * The ID of the selected region. */ - value: null | string; + value: string | undefined; width?: number; } diff --git a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx index 3d230d03b77..4c53d1cf9e2 100644 --- a/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx +++ b/packages/manager/src/components/SelectRegionPanel/SelectRegionPanel.tsx @@ -1,4 +1,3 @@ -import { Capabilities } from '@linode/api-v4/lib/regions'; import { useTheme } from '@mui/material'; import * as React from 'react'; import { useLocation } from 'react-router-dom'; @@ -25,8 +24,9 @@ import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; import { Box } from '../Box'; import { DocsLink } from '../DocsLink/DocsLink'; import { Link } from '../Link'; -import { RegionSelectProps } from '../RegionSelect/RegionSelect.types'; +import type { RegionSelectProps } from '../RegionSelect/RegionSelect.types'; +import type { Capabilities } from '@linode/api-v4/lib/regions'; import type { LinodeCreateType } from 'src/features/Linodes/LinodesCreate/types'; interface SelectRegionPanelProps { @@ -158,7 +158,7 @@ export const SelectRegionPanel = (props: SelectRegionPanelProps) => { onChange={(e, region) => handleSelection(region.id)} regionFilter={hideDistributedRegions ? 'core' : undefined} regions={regions ?? []} - value={selectedId || null} + value={selectedId} {...RegionSelectProps} /> {showClonePriceWarning && ( diff --git a/packages/manager/src/factories/types.ts b/packages/manager/src/factories/types.ts index b184dcbbbf5..192c8219ec2 100644 --- a/packages/manager/src/factories/types.ts +++ b/packages/manager/src/factories/types.ts @@ -173,6 +173,43 @@ export const volumeTypeFactory = Factory.Sync.makeFactory({ transfer: 0, }); +export const lkeStandardAvailabilityTypeFactory = Factory.Sync.makeFactory( + { + id: 'lke-sa', + label: 'LKE Standard Availability', + price: { + hourly: 0.0, + monthly: 0.0, + }, + region_prices: [], + transfer: 0, + } +); + +export const lkeHighAvailabilityTypeFactory = Factory.Sync.makeFactory( + { + id: 'lke-ha', + label: 'LKE High Availability', + price: { + hourly: 0.09, + monthly: 60.0, + }, + region_prices: [ + { + hourly: 0.108, + id: 'id-cgk', + monthly: 72.0, + }, + { + hourly: 0.126, + id: 'br-gru', + monthly: 84.0, + }, + ], + transfer: 0, + } +); + export const objectStorageTypeFactory = Factory.Sync.makeFactory({ id: 'objectstorage', label: 'Object Storage', diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index 87c9b675bc0..898f947a94a 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ import * as React from 'react'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; @@ -29,7 +28,7 @@ export const CloudPulseRegionSelect = React.memo( noMarginTop onChange={(e, region) => setRegion(region.id)} regions={regions ? regions : []} - value={null} + value={undefined} /> ); } diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index 7c38f687f8e..734db88e0cb 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -1,10 +1,3 @@ -import { Region } from '@linode/api-v4'; -import { - CreateKubeClusterPayload, - CreateNodePoolData, - KubeNodePoolResponse, -} from '@linode/api-v4/lib/kubernetes'; -import { APIError } from '@linode/api-v4/lib/types'; import { Divider } from '@mui/material'; import Grid from '@mui/material/Unstable_Grid2'; import { pick, remove, update } from 'ramda'; @@ -14,7 +7,7 @@ import { useHistory } from 'react-router-dom'; import { Box } from 'src/components/Box'; import { DocsLink } from 'src/components/DocsLink/DocsLink'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; +import Select from 'src/components/EnhancedSelect/Select'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; import { Notice } from 'src/components/Notice/Notice'; @@ -34,6 +27,7 @@ import { } from 'src/queries/account/agreements'; import { useCreateKubernetesClusterMutation, + useKubernetesTypesQuery, useKubernetesVersionQuery, } from 'src/queries/kubernetes'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -42,11 +36,9 @@ import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; import { extendType } from 'src/utilities/extendType'; import { filterCurrentTypes } from 'src/utilities/filterCurrentLinodeTypes'; import { plansNoticesUtils } from 'src/utilities/planNotices'; -import { - DOCS_LINK_LABEL_DC_PRICING, - LKE_HA_PRICE, -} from 'src/utilities/pricing/constants'; -import { getDCSpecificPrice } from 'src/utilities/pricing/dynamicPricing'; +import { DOCS_LINK_LABEL_DC_PRICING } from 'src/utilities/pricing/constants'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; +import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import KubeCheckoutBar from '../KubeCheckoutBar'; @@ -58,9 +50,19 @@ import { import { HAControlPlane } from './HAControlPlane'; import { NodePoolPanel } from './NodePoolPanel'; +import type { + CreateKubeClusterPayload, + CreateNodePoolData, + KubeNodePoolResponse, +} from '@linode/api-v4/lib/kubernetes'; +import type { APIError } from '@linode/api-v4/lib/types'; +import type { Item } from 'src/components/EnhancedSelect/Select'; + export const CreateCluster = () => { const { classes } = useStyles(); - const [selectedRegionID, setSelectedRegionID] = React.useState(''); + const [selectedRegionId, setSelectedRegionId] = React.useState< + string | undefined + >(); const [nodePools, setNodePools] = React.useState([]); const [label, setLabel] = React.useState(); const [version, setVersion] = React.useState | undefined>(); @@ -76,6 +78,16 @@ export const CreateCluster = () => { const { data: account } = useAccount(); const { showHighAvailability } = getKubeHighAvailability(account); + const { + data: kubernetesHighAvailabilityTypesData, + isError: isErrorKubernetesTypes, + isLoading: isLoadingKubernetesTypes, + } = useKubernetesTypesQuery(); + + const lkeHAType = kubernetesHighAvailabilityTypesData?.find( + (type) => type.id === 'lke-ha' + ); + const { data: allTypes, error: typesError, @@ -121,7 +133,7 @@ export const CreateCluster = () => { k8s_version, label, node_pools, - region: selectedRegionID, + region: selectedRegionId, }; createKubernetesCluster(payload) @@ -164,31 +176,23 @@ export const CreateCluster = () => { setLabel(newLabel ? newLabel : undefined); }; - /** - * @param regionId - region selection or null if no selection made - * @returns dynamically calculated high availability price by region - */ - const getHighAvailabilityPrice = (regionId: Region['id'] | null) => { - const dcSpecificPrice = regionId - ? getDCSpecificPrice({ basePrice: LKE_HA_PRICE, regionId }) - : undefined; - return dcSpecificPrice ? parseFloat(dcSpecificPrice) : undefined; - }; + const highAvailabilityPrice = getDCSpecificPriceByType({ + regionId: selectedRegionId, + type: lkeHAType, + }); const errorMap = getErrorMap( ['region', 'node_pools', 'label', 'k8s_version', 'versionLoad'], errors ); - const selectedId = selectedRegionID || null; - const { hasSelectedRegion, isPlanPanelDisabled, isSelectedRegionEligibleForPlan, } = plansNoticesUtils({ regionsData, - selectedRegionID, + selectedRegionID: selectedRegionId, }); if (typesError || regionsError || versionLoadError) { @@ -227,9 +231,9 @@ export const CreateCluster = () => { currentCapability="Kubernetes" disableClearable errorText={errorMap.region} - onChange={(e, region) => setSelectedRegionID(region.id)} + onChange={(e, region) => setSelectedRegionId(region.id)} regions={regionsData} - value={selectedId} + value={selectedRegionId} /> @@ -255,7 +259,14 @@ export const CreateCluster = () => { {showHighAvailability ? ( @@ -276,7 +287,7 @@ export const CreateCluster = () => { isPlanPanelDisabled={isPlanPanelDisabled} isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan} regionsData={regionsData} - selectedRegionId={selectedRegionID} + selectedRegionId={selectedRegionId} types={typesData || []} typesLoading={typesLoading} /> @@ -287,10 +298,15 @@ export const CreateCluster = () => { data-testid="kube-checkout-bar" > { createCluster={createCluster} hasAgreed={hasAgreed} highAvailability={highAvailability} - highAvailabilityPrice={getHighAvailabilityPrice(selectedId)} pools={nodePools} - region={selectedRegionID} + region={selectedRegionId} regionsData={regionsData} removePool={removePool} showHighAvailability={showHighAvailability} diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx index f6e654363fd..b8f995c02f1 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.test.tsx @@ -1,13 +1,18 @@ import { fireEvent } from '@testing-library/react'; import * as React from 'react'; -import { LKE_HA_PRICE } from 'src/utilities/pricing/constants'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { HAControlPlane, HAControlPlaneProps } from './HAControlPlane'; +import { HAControlPlane } from './HAControlPlane'; + +import type { HAControlPlaneProps } from './HAControlPlane'; const props: HAControlPlaneProps = { - highAvailabilityPrice: LKE_HA_PRICE, + highAvailabilityPrice: '60.00', + isErrorKubernetesTypes: false, + isLoadingKubernetesTypes: false, + selectedRegionId: 'us-southeast', setHighAvailability: vi.fn(), }; @@ -18,12 +23,17 @@ describe('HAControlPlane', () => { expect(getByTestId('ha-control-plane-form')).toBeVisible(); }); - it('should not render an HA price when the price is undefined', () => { - const { queryAllByText } = renderWithTheme( - + it('should not render an HA price when there is a price error', () => { + const { getByText } = renderWithTheme( + ); - expect(queryAllByText(/\$60\.00/)).toHaveLength(0); + getByText(/The cost for HA control plane is not available at this time./); + getByText(/For this region, HA control plane costs \$--.--\/month./); }); it('should render an HA price when the price is a number', async () => { diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx index 6acbfc82bd0..be39c12bb0b 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/HAControlPlane.tsx @@ -1,16 +1,20 @@ import { FormLabel } from '@mui/material'; import * as React from 'react'; -import { displayPrice } from 'src/components/DisplayPrice'; +import { CircleProgress } from 'src/components/CircleProgress'; import { FormControl } from 'src/components/FormControl'; import { FormControlLabel } from 'src/components/FormControlLabel'; import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; import { Radio } from 'src/components/Radio/Radio'; import { RadioGroup } from 'src/components/RadioGroup'; import { Typography } from 'src/components/Typography'; export interface HAControlPlaneProps { - highAvailabilityPrice: number | undefined; + highAvailabilityPrice: string; + isErrorKubernetesTypes: boolean; + isLoadingKubernetesTypes: boolean; + selectedRegionId: string | undefined; setHighAvailability: (ha: boolean | undefined) => void; } @@ -26,8 +30,23 @@ export const HACopy = () => ( ); +export const getRegionPriceLink = (selectedRegionId: string) => { + if (selectedRegionId === 'id-cgk') { + return 'https://www.linode.com/pricing/jakarta/#kubernetes'; + } else if (selectedRegionId === 'br-gru') { + return 'https://www.linode.com/pricing/sao-paulo/#kubernetes'; + } + return 'https://www.linode.com/pricing/#kubernetes'; +}; + export const HAControlPlane = (props: HAControlPlaneProps) => { - const { highAvailabilityPrice, setHighAvailability } = props; + const { + highAvailabilityPrice, + isErrorKubernetesTypes, + isLoadingKubernetesTypes, + selectedRegionId, + setHighAvailability, + } = props; const handleChange = (e: React.ChangeEvent) => { setHighAvailability(e.target.value === 'yes'); @@ -46,17 +65,31 @@ export const HAControlPlane = (props: HAControlPlaneProps) => { HA Control Plane + {isLoadingKubernetesTypes && selectedRegionId ? ( + + ) : selectedRegionId && isErrorKubernetesTypes ? ( + + + The cost for HA control plane is not available at this time. Refer + to pricing{' '} + for information. + + + ) : null} handleChange(e)} > + Yes, enable HA control plane.{' '} + {selectedRegionId + ? `For this region, HA control plane costs $${highAvailabilityPrice}/month.` + : '(Select a region to view price information.)'} + + } control={} name="yes" value="yes" diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx index 867b89ceea4..bbc316947a2 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/NodePoolPanel.tsx @@ -27,7 +27,7 @@ export interface NodePoolPanelProps { isPlanPanelDisabled: (planType?: LinodeTypeClass) => boolean; isSelectedRegionEligibleForPlan: (planType?: LinodeTypeClass) => boolean; regionsData: Region[]; - selectedRegionId: Region['id']; + selectedRegionId: Region['id'] | undefined; types: ExtendedType[]; typesError?: string; typesLoading: boolean; diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx index 64564568879..60e58baa7cc 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.test.tsx @@ -3,13 +3,13 @@ import * as React from 'react'; import { regionFactory } from 'src/factories'; import { nodePoolFactory } from 'src/factories/kubernetesCluster'; -import { - LKE_CREATE_CLUSTER_CHECKOUT_MESSAGE, - LKE_HA_PRICE, -} from 'src/utilities/pricing/constants'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; +import { LKE_CREATE_CLUSTER_CHECKOUT_MESSAGE } from 'src/utilities/pricing/constants'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import KubeCheckoutBar, { Props } from './KubeCheckoutBar'; +import KubeCheckoutBar from './KubeCheckoutBar'; + +import type { Props } from './KubeCheckoutBar'; const pools = nodePoolFactory.buildList(5, { count: 3, type: 'g6-standard-1' }); @@ -17,7 +17,7 @@ const props: Props = { createCluster: vi.fn(), hasAgreed: false, highAvailability: false, - highAvailabilityPrice: LKE_HA_PRICE, + highAvailabilityPrice: '60', pools, region: 'us-east', regionsData: regionFactory.buildList(1), @@ -34,7 +34,7 @@ const renderComponent = (_props: Props) => describe('KubeCheckoutBar', () => { it('should render helper text and disable create button until a region has been selected', async () => { const { findByText, getByTestId, getByText } = renderWithTheme( - + ); await waitForElementToBeRemoved(getByTestId('circle-progress')); @@ -84,12 +84,41 @@ describe('KubeCheckoutBar', () => { await findByText(/\$210\.00/); }); - it('should display the DC-Specific total price of the cluster for a region with a price increase', async () => { + it('should display the DC-Specific total price of the cluster for a region with a price increase without HA selection', async () => { const { findByText } = renderWithTheme( ); - // 5 node pools * 3 linodes per pool * 10 per linode * 20% increase for Jakarta + // 5 node pools * 3 linodes per pool * 12 per linode * 20% increase for Jakarta + 72 per month per cluster for HA + await findByText(/\$180\.00/); + }); + + it('should display the DC-Specific total price of the cluster for a region with a price increase with HA selection', async () => { + const { findByText } = renderWithTheme( + + ); + + // 5 node pools * 3 linodes per pool * 12 per linode * 20% increase for Jakarta + 72 per month per cluster for HA + await findByText(/\$252\.00/); + }); + + it('should display UNKNOWN_PRICE for HA when not available and show total price of cluster as the sum of the node pools', async () => { + const { findByText, getByText } = renderWithTheme( + + ); + + // 5 node pools * 3 linodes per pool * 12 per linode * 20% increase for Jakarta + UNKNOWN_PRICE await findByText(/\$180\.00/); + getByText(/\$--.--\/month/); }); }); diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx index 16d3f07758e..c2cfb96908a 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx @@ -1,11 +1,9 @@ -import { KubeNodePoolResponse, Region } from '@linode/api-v4'; import { Typography, styled } from '@mui/material'; import * as React from 'react'; import { Box } from 'src/components/Box'; import { CheckoutBar } from 'src/components/CheckoutBar/CheckoutBar'; import { CircleProgress } from 'src/components/CircleProgress'; -import { displayPrice } from 'src/components/DisplayPrice'; import { Divider } from 'src/components/Divider'; import { Notice } from 'src/components/Notice/Notice'; import { RenderGuard } from 'src/components/RenderGuard'; @@ -22,15 +20,17 @@ import { } from 'src/utilities/pricing/kubernetes'; import { nodeWarning } from '../kubeUtils'; -import NodePoolSummary from './NodePoolSummary'; +import { NodePoolSummary } from './NodePoolSummary'; + +import type { KubeNodePoolResponse, Region } from '@linode/api-v4'; export interface Props { createCluster: () => void; hasAgreed: boolean; highAvailability?: boolean; - highAvailabilityPrice: number | undefined; + highAvailabilityPrice: string; pools: KubeNodePoolResponse[]; - region: string; + region: string | undefined; regionsData: Region[]; removePool: (poolIdx: number) => void; showHighAvailability: boolean | undefined; @@ -39,7 +39,7 @@ export interface Props { updatePool: (poolIdx: number, updatedPool: KubeNodePoolResponse) => void; } -export const KubeCheckoutBar: React.FC = (props) => { +export const KubeCheckoutBar = (props: Props) => { const { createCluster, hasAgreed, @@ -81,7 +81,7 @@ export const KubeCheckoutBar: React.FC = (props) => { highAvailabilityPrice !== undefined; const disableCheckout = Boolean( - needsAPool || gdprConditions || haConditions || region === '' + needsAPool || gdprConditions || haConditions || !region ); if (isLoading) { @@ -96,10 +96,10 @@ export const KubeCheckoutBar: React.FC = (props) => { ) : undefined } calculatedPrice={ - region !== '' + region ? getTotalClusterPrice({ highAvailabilityPrice: highAvailability - ? highAvailabilityPrice + ? Number(highAvailabilityPrice) : undefined, pools, region, @@ -122,7 +122,7 @@ export const KubeCheckoutBar: React.FC = (props) => { types?.find((thisType) => thisType.id === thisPool.type) || null } price={ - region !== '' + region ? getKubernetesMonthlyPrice({ count: thisPool.count, region, @@ -148,14 +148,12 @@ export const KubeCheckoutBar: React.FC = (props) => { variant="warning" /> )} - {region != '' && highAvailability ? ( + {region && highAvailability ? ( High Availability (HA) Control Plane - - {displayPrice(Number(highAvailabilityPrice))}/month - + {`$${highAvailabilityPrice}/month`} ) : undefined} diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.test.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.test.tsx index c264ea67f85..26927ba9879 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.test.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.test.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { extendedTypes } from 'src/__data__/ExtendedType'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import NodePoolSummary, { Props } from './NodePoolSummary'; +import { NodePoolSummary, Props } from './NodePoolSummary'; const props: Props = { nodeCount: 3, diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.tsx index 6d9f5a7923f..a36523911dc 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.tsx @@ -1,7 +1,7 @@ import Close from '@mui/icons-material/Close'; import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; import { Box } from 'src/components/Box'; import { DisplayPrice } from 'src/components/DisplayPrice'; @@ -55,7 +55,7 @@ export interface Props { updateNodeCount: (count: number) => void; } -export const NodePoolSummary: React.FC = (props) => { +export const NodePoolSummary = React.memo((props: Props) => { const { classes } = useStyles(); const { nodeCount, onRemove, poolType, price, updateNodeCount } = props; @@ -109,6 +109,4 @@ export const NodePoolSummary: React.FC = (props) => { ); -}; - -export default React.memo(NodePoolSummary); +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx index e84e1621b04..d7f2407acb3 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx @@ -1,21 +1,31 @@ -import { KubernetesCluster } from '@linode/api-v4'; +import { useTheme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; -import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; +import { CircleProgress } from 'src/components/CircleProgress'; +import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; -import { useAllKubernetesNodePoolQuery } from 'src/queries/kubernetes'; +import { + useAllKubernetesNodePoolQuery, + useKubernetesTypesQuery, +} from 'src/queries/kubernetes'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useSpecificTypes } from 'src/queries/types'; import { extendTypesQueryResult } from 'src/utilities/extendType'; import { pluralize } from 'src/utilities/pluralize'; -import { LKE_HA_PRICE } from 'src/utilities/pricing/constants'; -import { getDCSpecificPrice } from 'src/utilities/pricing/dynamicPricing'; +import { + HA_PRICE_ERROR_MESSAGE, + UNKNOWN_PRICE, +} from 'src/utilities/pricing/constants'; +import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; import { getTotalClusterPrice } from 'src/utilities/pricing/kubernetes'; import { getTotalClusterMemoryCPUAndStorage } from '../kubeUtils'; +import type { KubernetesCluster } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; + interface Props { cluster: KubernetesCluster; } @@ -45,15 +55,19 @@ const useStyles = makeStyles()((theme: Theme) => ({ marginBottom: theme.spacing(3), padding: `${theme.spacing(2.5)} ${theme.spacing(2.5)} ${theme.spacing(3)}`, }, + tooltip: { + '& .MuiTooltip-tooltip': { + minWidth: 320, + }, + }, })); -export const KubeClusterSpecs = (props: Props) => { +export const KubeClusterSpecs = React.memo((props: Props) => { const { cluster } = props; const { classes } = useStyles(); const { data: regions } = useRegionsQuery(); - + const theme = useTheme(); const { data: pools } = useAllKubernetesNodePoolQuery(cluster.id); - const typesQuery = useSpecificTypes(pools?.map((pool) => pool.type) ?? []); const types = extendTypesQueryResult(typesQuery); @@ -62,30 +76,53 @@ export const KubeClusterSpecs = (props: Props) => { types ?? [] ); - const region = regions?.find((r) => r.id === cluster.region); + const { + data: kubernetesHighAvailabilityTypesData, + isError: isErrorKubernetesTypes, + isLoading: isLoadingKubernetesTypes, + } = useKubernetesTypesQuery(); - const displayRegion = region?.label ?? cluster.region; + const lkeHAType = kubernetesHighAvailabilityTypesData?.find( + (type) => type.id === 'lke-ha' + ); - const dcSpecificPrice = cluster.control_plane.high_availability - ? getDCSpecificPrice({ - basePrice: LKE_HA_PRICE, - regionId: region?.id, - }) - : undefined; + const region = regions?.find((r) => r.id === cluster.region); + const displayRegion = region?.label ?? cluster.region; - const highAvailabilityPrice = dcSpecificPrice - ? parseFloat(dcSpecificPrice) + const highAvailabilityPrice = cluster.control_plane.high_availability + ? getDCSpecificPriceByType({ regionId: region?.id, type: lkeHAType }) : undefined; const kubeSpecsLeft = [ `Version ${cluster.k8s_version}`, displayRegion, - `$${getTotalClusterPrice({ - highAvailabilityPrice, - pools: pools ?? [], - region: region?.id, - types: types ?? [], - }).toFixed(2)}/month`, + isLoadingKubernetesTypes ? ( + + ) : cluster.control_plane.high_availability && isErrorKubernetesTypes ? ( + <> + ${UNKNOWN_PRICE}/month + + + ) : ( + `$${getTotalClusterPrice({ + highAvailabilityPrice: highAvailabilityPrice + ? Number(highAvailabilityPrice) + : undefined, + pools: pools ?? [], + region: region?.id, + types: types ?? [], + }).toFixed(2)}/month` + ), ]; const kubeSpecsRight = [ @@ -115,6 +152,4 @@ export const KubeClusterSpecs = (props: Props) => { {kubeSpecsRight.map(kubeSpecItem)}
    ); -}; - -export default KubeClusterSpecs; +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx index 56436e6aab2..e3f2a409034 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx @@ -1,6 +1,4 @@ -import { KubernetesCluster } from '@linode/api-v4/lib/kubernetes'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import { Theme } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -12,7 +10,7 @@ import { Chip } from 'src/components/Chip'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Paper } from 'src/components/Paper'; import { TagCell } from 'src/components/TagCell/TagCell'; -import KubeClusterSpecs from 'src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs'; +import { KubeClusterSpecs } from 'src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useKubernetesClusterMutation, @@ -25,6 +23,9 @@ import { DeleteKubernetesClusterDialog } from './DeleteKubernetesClusterDialog'; import { KubeConfigDisplay } from './KubeConfigDisplay'; import { KubeConfigDrawer } from './KubeConfigDrawer'; +import type { KubernetesCluster } from '@linode/api-v4/lib/kubernetes'; +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()((theme: Theme) => ({ actionRow: { '& button': { @@ -100,7 +101,7 @@ interface Props { cluster: KubernetesCluster; } -export const KubeSummaryPanel = (props: Props) => { +export const KubeSummaryPanel = React.memo((props: Props) => { const { cluster } = props; const { classes } = useStyles(); const { enqueueSnackbar } = useSnackbar(); @@ -258,6 +259,4 @@ export const KubeSummaryPanel = (props: Props) => { ); -}; - -export default React.memo(KubeSummaryPanel); +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx index 9e8383e09b2..93cc3987a39 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubernetesClusterDetail.tsx @@ -15,7 +15,7 @@ import { import { useRegionsQuery } from 'src/queries/regions/regions'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import KubeSummaryPanel from './KubeSummaryPanel'; +import { KubeSummaryPanel } from './KubeSummaryPanel'; import { NodePoolsDisplay } from './NodePoolsDisplay/NodePoolsDisplay'; import { UpgradeKubernetesClusterToHADialog } from './UpgradeClusterDialog'; import UpgradeKubernetesVersionBanner from './UpgradeKubernetesVersionBanner'; @@ -25,24 +25,21 @@ export const KubernetesClusterDetail = () => { const { clusterID } = useParams<{ clusterID: string }>(); const id = Number(clusterID); const location = useLocation(); - const { data: cluster, error, isLoading } = useKubernetesClusterQuery(id); - const { data: regionsData } = useRegionsQuery(); const { mutateAsync: updateKubernetesCluster } = useKubernetesClusterMutation( id ); - const [updateError, setUpdateError] = React.useState(); - - const [isUpgradeToHAOpen, setIsUpgradeToHAOpen] = React.useState(false); - const { isClusterHighlyAvailable, showHighAvailability, } = getKubeHighAvailability(account, cluster); + const [updateError, setUpdateError] = React.useState(); + const [isUpgradeToHAOpen, setIsUpgradeToHAOpen] = React.useState(false); + if (error) { return ( { ); }; - -export default KubernetesClusterDetail; diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/UpgradeClusterDialog.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/UpgradeClusterDialog.tsx index 6b697426e6a..b61c0226f54 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/UpgradeClusterDialog.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/UpgradeClusterDialog.tsx @@ -1,10 +1,10 @@ -import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import { useSnackbar } from 'notistack'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Checkbox } from 'src/components/Checkbox'; +import { CircleProgress } from 'src/components/CircleProgress'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; @@ -12,12 +12,17 @@ import { localStorageWarning, nodesDeletionWarning, } from 'src/features/Kubernetes/kubeUtils'; -import { useKubernetesClusterMutation } from 'src/queries/kubernetes'; -import { LKE_HA_PRICE } from 'src/utilities/pricing/constants'; -import { getDCSpecificPrice } from 'src/utilities/pricing/dynamicPricing'; +import { + useKubernetesClusterMutation, + useKubernetesTypesQuery, +} from 'src/queries/kubernetes'; +import { HA_UPGRADE_PRICE_ERROR_MESSAGE } from 'src/utilities/pricing/constants'; +import { getDCSpecificPriceByType } from 'src/utilities/pricing/dynamicPricing'; import { HACopy } from '../CreateCluster/HAControlPlane'; +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()((theme: Theme) => ({ noticeHeader: { fontSize: '0.875rem', @@ -39,11 +44,10 @@ interface Props { regionID: string; } -export const UpgradeKubernetesClusterToHADialog = (props: Props) => { +export const UpgradeKubernetesClusterToHADialog = React.memo((props: Props) => { const { clusterID, onClose, open, regionID } = props; const { enqueueSnackbar } = useSnackbar(); const [checked, setChecked] = React.useState(false); - const toggleChecked = () => setChecked((isChecked) => !isChecked); const { mutateAsync: updateKubernetesCluster } = useKubernetesClusterMutation( @@ -53,6 +57,16 @@ export const UpgradeKubernetesClusterToHADialog = (props: Props) => { const [submitting, setSubmitting] = React.useState(false); const { classes } = useStyles(); + const { + data: kubernetesHighAvailabilityTypesData, + isError: isErrorKubernetesTypes, + isLoading: isLoadingKubernetesTypes, + } = useKubernetesTypesQuery(); + + const lkeHAType = kubernetesHighAvailabilityTypesData?.find( + (type) => type.id === 'lke-ha' + ); + const onUpgrade = () => { setSubmitting(true); setError(undefined); @@ -70,6 +84,11 @@ export const UpgradeKubernetesClusterToHADialog = (props: Props) => { }); }; + const highAvailabilityPrice = getDCSpecificPriceByType({ + regionId: regionID, + type: lkeHAType, + }); + const actions = ( { open={open} title="Upgrade to High Availability" > - - - For this region, pricing for the HA control plane is $ - {getDCSpecificPrice({ - basePrice: LKE_HA_PRICE, - regionId: regionID, - })}{' '} - per month per cluster. - - - - Caution: - -
      -
    • {nodesDeletionWarning}
    • -
    • {localStorageWarning}
    • -
    • - This may take several minutes, as nodes will be replaced on a - rolling basis. -
    • -
    -
    - + {isLoadingKubernetesTypes ? ( + + ) : ( + <> + + {isErrorKubernetesTypes ? ( + + {HA_UPGRADE_PRICE_ERROR_MESSAGE} + + ) : ( + <> + + For this region, pricing for the HA control plane is $ + {highAvailabilityPrice} per month per cluster. + + + + Caution: + +
      +
    • {nodesDeletionWarning}
    • +
    • {localStorageWarning}
    • +
    • + This may take several minutes, as nodes will be replaced on + a rolling basis. +
    • +
    +
    + + + )} + + )} ); -}; +}); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/index.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/index.tsx deleted file mode 100644 index 2dd9213e1e0..00000000000 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from './KubernetesClusterDetail'; diff --git a/packages/manager/src/features/Kubernetes/index.tsx b/packages/manager/src/features/Kubernetes/index.tsx index a25b9a50694..82aebd910fd 100644 --- a/packages/manager/src/features/Kubernetes/index.tsx +++ b/packages/manager/src/features/Kubernetes/index.tsx @@ -7,21 +7,29 @@ import { SuspenseLoader } from 'src/components/SuspenseLoader'; const KubernetesLanding = React.lazy( () => import('./KubernetesLanding/KubernetesLanding') ); + const ClusterCreate = React.lazy(() => import('./CreateCluster/CreateCluster').then((module) => ({ default: module.CreateCluster, })) ); -const ClusterDetail = React.lazy(() => import('./KubernetesClusterDetail')); -const Kubernetes: React.FC = () => { +const KubernetesClusterDetail = React.lazy(() => + import('./KubernetesClusterDetail/KubernetesClusterDetail').then( + (module) => ({ + default: module.KubernetesClusterDetail, + }) + ) +); + +export const Kubernetes = () => { return ( }> @@ -43,5 +51,3 @@ const Kubernetes: React.FC = () => { ); }; - -export default Kubernetes; diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.ts b/packages/manager/src/features/Kubernetes/kubeUtils.ts index e19fe18873a..9b91404a0ec 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.ts @@ -1,11 +1,10 @@ -import { Account } from '@linode/api-v4/lib/account'; -import { +import type { Account } from '@linode/api-v4/lib/account'; +import type { KubeNodePoolResponse, KubernetesCluster, KubernetesVersion, } from '@linode/api-v4/lib/kubernetes'; -import { Region } from '@linode/api-v4/lib/regions'; - +import type { Region } from '@linode/api-v4/lib/regions'; import type { ExtendedType } from 'src/utilities/extendType'; export const nodeWarning = `We recommend a minimum of 3 nodes in each Node Pool to avoid downtime during upgrades and maintenance.`; diff --git a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx index b10784efb62..607a1bb09a0 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.tsx @@ -41,7 +41,7 @@ interface Props { handleSelectRegion: (id: string) => void; helperText?: string; linodeType: Linode['type']; - selectedRegion: null | string; + selectedRegion: string | undefined; } export type MigratePricePanelType = 'current' | 'new'; diff --git a/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx b/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx index b9a2986f3ef..65cc84a2465 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx @@ -1,4 +1,3 @@ -import { Event } from '@linode/api-v4/lib/account'; import { styled, useTheme } from '@mui/material/styles'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -45,6 +44,7 @@ import { CautionNotice } from './CautionNotice'; import { ConfigureForm } from './ConfigureForm'; import type { PlacementGroup } from '@linode/api-v4'; +import type { Event } from '@linode/api-v4/lib/account'; interface Props { linodeId: number | undefined; @@ -98,9 +98,9 @@ export const MigrateLinode = React.memo((props: Props) => { const { data: regionsData } = useRegionsQuery(); const flags = useFlags(); - const [selectedRegion, handleSelectRegion] = React.useState( - null - ); + const [selectedRegion, handleSelectRegion] = React.useState< + string | undefined + >(); const [ placementGroupSelection, setPlacementGroupSelection, @@ -116,7 +116,7 @@ export const MigrateLinode = React.memo((props: Props) => { agreements, profile, regions: regionsData, - selectedRegionId: selectedRegion ?? '', + selectedRegionId: selectedRegion, }); React.useEffect(() => { @@ -129,7 +129,7 @@ export const MigrateLinode = React.memo((props: Props) => { if (open) { reset(); setConfirmed(false); - handleSelectRegion(null); + handleSelectRegion(undefined); } }, [open]); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx index a4fbb9b9152..40d12acbfab 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRegions.tsx @@ -9,7 +9,7 @@ interface Props { onBlur: (e: any) => void; onChange: (value: string) => void; required?: boolean; - selectedRegion: null | string; + selectedRegion: string | undefined; } export const BucketRegions = (props: Props) => { diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx index e7bf8c4feb1..104da2923f8 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx @@ -1,4 +1,3 @@ -import { UpdateVPCPayload, VPC } from '@linode/api-v4/lib/vpcs/types'; import { updateVPCSchema } from '@linode/validation/lib/vpcs.schema'; import { useFormik } from 'formik'; import * as React from 'react'; @@ -13,6 +12,8 @@ import { useRegionsQuery } from 'src/queries/regions/regions'; import { useUpdateVPCMutation } from 'src/queries/vpcs/vpcs'; import { getErrorMap } from 'src/utilities/errorUtils'; +import type { UpdateVPCPayload, VPC } from '@linode/api-v4/lib/vpcs/types'; + interface Props { onClose: () => void; open: boolean; @@ -120,10 +121,10 @@ export const VPCEditDrawer = (props: Props) => { currentCapability="VPCs" disabled // the Region field will not be editable during beta errorText={(regionsError && regionsError[0].reason) || undefined} - onChange={() => null} helperText={REGION_HELPER_TEXT} + onChange={() => null} regions={regionsData} - value={vpc?.region ?? null} + value={vpc?.region} /> )} { + const lkeTypes = [ + lkeStandardAvailabilityTypeFactory.build(), + lkeHighAvailabilityTypeFactory.build(), + ]; + return HttpResponse.json(makeResourcePage(lkeTypes)); + }), http.get('*/lke/versions', async () => { const versions = kubernetesVersionFactory.buildList(1); return HttpResponse.json(makeResourcePage(versions)); diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index e9573ad5283..39048c38627 100644 --- a/packages/manager/src/queries/kubernetes.ts +++ b/packages/manager/src/queries/kubernetes.ts @@ -1,12 +1,4 @@ import { - CreateKubeClusterPayload, - CreateNodePoolData, - KubeNodePoolResponse, - KubernetesCluster, - KubernetesDashboardResponse, - KubernetesEndpointResponse, - KubernetesVersion, - UpdateNodePoolData, createKubernetesCluster, createNodePool, deleteKubernetesCluster, @@ -16,6 +8,7 @@ import { getKubernetesClusterDashboard, getKubernetesClusterEndpoints, getKubernetesClusters, + getKubernetesTypes, getKubernetesVersions, getNodePools, recycleAllNodes, @@ -25,12 +18,6 @@ import { updateKubernetesCluster, updateNodePool, } from '@linode/api-v4'; -import { - APIError, - Filter, - Params, - ResourcePage, -} from '@linode/api-v4/lib/types'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -39,6 +26,24 @@ import { getAll } from 'src/utilities/getAll'; import { queryPresets } from './base'; import { profileQueries } from './profile/profile'; +import type { + CreateKubeClusterPayload, + CreateNodePoolData, + KubeNodePoolResponse, + KubernetesCluster, + KubernetesDashboardResponse, + KubernetesEndpointResponse, + KubernetesVersion, + UpdateNodePoolData, +} from '@linode/api-v4'; +import type { + APIError, + Filter, + Params, + PriceType, + ResourcePage, +} from '@linode/api-v4/lib/types'; + export const kubernetesQueries = createQueryKeys('kubernetes', { cluster: (id: number) => ({ contextQueries: { @@ -78,6 +83,10 @@ export const kubernetesQueries = createQueryKeys('kubernetes', { }, queryKey: null, }, + types: { + queryFn: () => getAllKubernetesTypes(), + queryKey: null, + }, versions: { queryFn: () => getAllKubernetesVersions(), queryKey: null, @@ -312,3 +321,14 @@ const getAllAPIEndpointsForCluster = (clusterId: number) => getAll((params, filters) => getKubernetesClusterEndpoints(clusterId, params, filters) )().then((data) => data.data); + +const getAllKubernetesTypes = () => + getAll((params) => getKubernetesTypes(params))().then( + (results) => results.data + ); + +export const useKubernetesTypesQuery = () => + useQuery({ + ...queryPresets.oneTimeFetch, + ...kubernetesQueries.types, + }); diff --git a/packages/manager/src/utilities/formatRegion.ts b/packages/manager/src/utilities/formatRegion.ts index 3b8f92c95b6..a2f7046b93b 100644 --- a/packages/manager/src/utilities/formatRegion.ts +++ b/packages/manager/src/utilities/formatRegion.ts @@ -2,9 +2,9 @@ import { CONTINENT_CODE_TO_CONTINENT, COUNTRY_CODE_TO_CONTINENT_CODE, } from '@linode/api-v4'; -import { Region } from '@linode/api-v4'; import type { Agreements, Country, Profile } from '@linode/api-v4'; +import type { Region } from '@linode/api-v4'; interface GDPRConfiguration { /** The user's agreements */ @@ -14,7 +14,7 @@ interface GDPRConfiguration { /** The list of regions */ regions: Region[] | undefined; /** The ID of the selected region (e.g. 'eu-west') */ - selectedRegionId: string; + selectedRegionId: string | undefined; } export const getRegionCountryGroup = (region: Region | undefined) => { @@ -34,14 +34,14 @@ export const getRegionCountryGroup = (region: Region | undefined) => { export const getSelectedRegion = ( regions: Region[], - selectedRegionId: string + selectedRegionId: string | undefined ): Region | undefined => { return regions.find((thisRegion) => selectedRegionId === thisRegion.id); }; export const getSelectedRegionGroup = ( regions: Region[], - selectedRegionId: string + selectedRegionId: string | undefined ): string | undefined => { const selectedRegion = getSelectedRegion(regions, selectedRegionId); diff --git a/packages/manager/src/utilities/pricing/constants.ts b/packages/manager/src/utilities/pricing/constants.ts index 9dbd49f171e..f7a667ca1fa 100644 --- a/packages/manager/src/utilities/pricing/constants.ts +++ b/packages/manager/src/utilities/pricing/constants.ts @@ -1,11 +1,10 @@ -// These values will eventually come from the API, but for now they are hardcoded and -// used to generate the region based dynamic pricing. -export const LKE_HA_PRICE = 60; - export const UNKNOWN_PRICE = '--.--'; export const PRICE_ERROR_TOOLTIP_TEXT = 'There was an error loading the price.'; export const PRICES_RELOAD_ERROR_NOTICE_TEXT = 'There was an error retrieving prices. Please reload and try again.'; +export const HA_UPGRADE_PRICE_ERROR_MESSAGE = + 'Upgrading to HA is not available at this time. Try again later.'; +export const HA_PRICE_ERROR_MESSAGE = `The cost for HA control plane is not available at this time.`; // Other constants export const PLAN_SELECTION_NO_REGION_SELECTED_MESSAGE = diff --git a/packages/manager/src/utilities/pricing/dynamicPricing.test.ts b/packages/manager/src/utilities/pricing/dynamicPricing.test.ts index 56c73482029..f8641ae2afd 100644 --- a/packages/manager/src/utilities/pricing/dynamicPricing.test.ts +++ b/packages/manager/src/utilities/pricing/dynamicPricing.test.ts @@ -1,4 +1,5 @@ import { + lkeHighAvailabilityTypeFactory, nodeBalancerTypeFactory, volumeTypeFactory, } from 'src/factories/types'; @@ -49,6 +50,7 @@ describe('getDCSpecificPricingDisplay', () => { describe('getDCSpecificPricingByType', () => { const mockNodeBalancerType = nodeBalancerTypeFactory.build(); const mockVolumeType = volumeTypeFactory.build(); + const mockLKEHighAvailabilityType = lkeHighAvailabilityTypeFactory.build(); it('calculates dynamic pricing for a region without an increase', () => { expect( @@ -57,6 +59,13 @@ describe('getDCSpecificPricingByType', () => { type: mockNodeBalancerType, }) ).toBe('10.00'); + + expect( + getDCSpecificPriceByType({ + regionId: 'us-east', + type: mockLKEHighAvailabilityType, + }) + ).toBe('60.00'); }); it('calculates dynamic pricing for a region with an increase', () => { @@ -73,6 +82,20 @@ describe('getDCSpecificPricingByType', () => { type: mockNodeBalancerType, }) ).toBe('14.00'); + + expect( + getDCSpecificPriceByType({ + regionId: 'id-cgk', + type: mockLKEHighAvailabilityType, + }) + ).toBe('72.00'); + + expect( + getDCSpecificPriceByType({ + regionId: 'br-gru', + type: mockLKEHighAvailabilityType, + }) + ).toBe('84.00'); }); it('calculates dynamic pricing for a region without an increase on an hourly interval to the specified decimal', () => { diff --git a/packages/manager/src/utilities/pricing/kubernetes.test.tsx b/packages/manager/src/utilities/pricing/kubernetes.test.tsx index 42f9225c829..6a76f0329bb 100644 --- a/packages/manager/src/utilities/pricing/kubernetes.test.tsx +++ b/packages/manager/src/utilities/pricing/kubernetes.test.tsx @@ -1,6 +1,5 @@ import { linodeTypeFactory, nodePoolFactory } from 'src/factories'; import { extendType } from 'src/utilities/extendType'; -import { LKE_HA_PRICE } from 'src/utilities/pricing/constants'; import { getKubernetesMonthlyPrice, getTotalClusterPrice } from './kubernetes'; @@ -23,6 +22,7 @@ describe('helper functions', () => { type: 'not-a-real-type', }); const region = 'us_east'; + const LKE_HA_PRICE = 60; describe('getMonthlyPrice', () => { it('should multiply node price by node count', () => { diff --git a/packages/manager/src/utilities/pricing/kubernetes.ts b/packages/manager/src/utilities/pricing/kubernetes.ts index 1532281a180..ab753c5a15d 100644 --- a/packages/manager/src/utilities/pricing/kubernetes.ts +++ b/packages/manager/src/utilities/pricing/kubernetes.ts @@ -1,6 +1,7 @@ +import { getLinodeRegionPrice } from './linodes'; + import type { KubeNodePoolResponse, Region } from '@linode/api-v4/lib'; import type { ExtendedType } from 'src/utilities/extendType'; -import { getLinodeRegionPrice } from './linodes'; interface MonthlyPriceOptions { count: number; From 5ec87a1febcf5fbd51cee069c89b8b649657b564 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Tue, 18 Jun 2024 18:13:12 -0400 Subject: [PATCH 103/163] test: [M3-7330] - Attempt to fix hanging unit tests (#10591) * Use React Query infinity cache time for unit tests * Improve event test stability by mocking the current system time when comparing relative date strings * Apply `pool: forks` option to eliminate hanging * Added changeset: Fix hanging unit tests --- .../.changeset/pr-10591-tests-1718746383365.md | 5 +++++ .../manager/src/features/Events/utils.test.tsx | 9 +++++++++ packages/manager/src/hooks/useOrder.test.tsx | 5 +---- packages/manager/src/index.tsx | 2 +- packages/manager/src/queries/base.ts | 17 +++++++++++++++-- packages/manager/vite.config.ts | 1 + 6 files changed, 32 insertions(+), 7 deletions(-) create mode 100644 packages/manager/.changeset/pr-10591-tests-1718746383365.md diff --git a/packages/manager/.changeset/pr-10591-tests-1718746383365.md b/packages/manager/.changeset/pr-10591-tests-1718746383365.md new file mode 100644 index 00000000000..fe1bc7bcd75 --- /dev/null +++ b/packages/manager/.changeset/pr-10591-tests-1718746383365.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Fix hanging unit tests ([#10591](https://github.com/linode/manager/pull/10591)) diff --git a/packages/manager/src/features/Events/utils.test.tsx b/packages/manager/src/features/Events/utils.test.tsx index 5367809d580..89ce3f0c328 100644 --- a/packages/manager/src/features/Events/utils.test.tsx +++ b/packages/manager/src/features/Events/utils.test.tsx @@ -8,6 +8,7 @@ import { } from './utils'; import type { Event } from '@linode/api-v4'; +import { DateTime } from 'luxon'; describe('getEventMessage', () => { const mockEvent1: Event = eventFactory.build({ @@ -126,6 +127,10 @@ describe('formatProgressEvent', () => { }); it('returns the correct format for a finished Event', () => { + const currentDateMock = DateTime.fromISO(mockEvent1.created).plus({ + seconds: 1, + }); + vi.setSystemTime(currentDateMock.toJSDate()); const { progressEventDisplay, showProgress } = formatProgressEvent( mockEvent1 ); @@ -135,6 +140,10 @@ describe('formatProgressEvent', () => { }); it('returns the correct format for a "started" event without time remaining info', () => { + const currentDateMock = DateTime.fromISO(mockEvent2.created).plus({ + seconds: 1, + }); + vi.setSystemTime(currentDateMock.toJSDate()); const { progressEventDisplay, showProgress } = formatProgressEvent( mockEvent2 ); diff --git a/packages/manager/src/hooks/useOrder.test.tsx b/packages/manager/src/hooks/useOrder.test.tsx index 5a31d40d4fe..4451588aa48 100644 --- a/packages/manager/src/hooks/useOrder.test.tsx +++ b/packages/manager/src/hooks/useOrder.test.tsx @@ -1,6 +1,4 @@ -import { QueryClient } from '@tanstack/react-query'; import { act, renderHook, waitFor } from '@testing-library/react'; - import { HttpResponse, http, server } from 'src/mocks/testServer'; import { queryClientFactory } from 'src/queries/base'; import { usePreferences } from 'src/queries/profile/preferences'; @@ -77,8 +75,7 @@ describe('useOrder hook', () => { }); it('use preferences are used when there are no query params', async () => { - const queryClient = new QueryClient(); - + const queryClient = queryClientFactory(); server.use( http.get('*/profile/preferences', () => { return HttpResponse.json({ diff --git a/packages/manager/src/index.tsx b/packages/manager/src/index.tsx index dd965472ab0..06a0153ff9d 100644 --- a/packages/manager/src/index.tsx +++ b/packages/manager/src/index.tsx @@ -21,7 +21,7 @@ import './index.css'; import { LinodeThemeWrapper } from './LinodeThemeWrapper'; import { queryClientFactory } from './queries/base'; -const queryClient = queryClientFactory(); +const queryClient = queryClientFactory('longLived'); const store = storeFactory(); setupInterceptors(store); diff --git a/packages/manager/src/queries/base.ts b/packages/manager/src/queries/base.ts index 072b0035700..b4157095922 100644 --- a/packages/manager/src/queries/base.ts +++ b/packages/manager/src/queries/base.ts @@ -31,9 +31,22 @@ export const queryPresets = { }, }; -export const queryClientFactory = () => { +/** + * Creates and returns a new TanStack Query query client instance. + * + * Allows the query client behavior to be configured by specifying a preset. The + * 'longLived' preset is most suitable for production use, while 'oneTimeFetch' is + * preferred for tests. + * + * @param preset - Optional query preset for client. Either 'longLived' or 'oneTimeFetch'. + * + * @returns New `QueryClient` instance. + */ +export const queryClientFactory = ( + preset: 'longLived' | 'oneTimeFetch' = 'oneTimeFetch' +) => { return new QueryClient({ - defaultOptions: { queries: queryPresets.longLived }, + defaultOptions: { queries: queryPresets[preset] }, }); }; diff --git a/packages/manager/vite.config.ts b/packages/manager/vite.config.ts index 614c59091d3..4b1d85d1f14 100644 --- a/packages/manager/vite.config.ts +++ b/packages/manager/vite.config.ts @@ -35,6 +35,7 @@ export default defineConfig({ 'src/**/*.utils.{js,jsx,ts,tsx}', ], }, + pool: 'forks', environment: 'jsdom', globals: true, setupFiles: './src/testSetup.ts', From 284d3a4ffd617605f60740a4e6041e71b70076c8 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Thu, 20 Jun 2024 11:52:19 -0500 Subject: [PATCH 104/163] test: Unit test coverage - HostNameTableCell (#10596) * test coverage - HostNameTableCell * Added changeset: Unit test coverage - HostNameTableCell * PR - feedback - @bnussman @jaalah-akamai * Update packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx Co-authored-by: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> * PR - feedback - @abailly-akamai * code cleanup * Update packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> --------- Co-authored-by: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> --- .../pr-10596-tests-1718806691101.md | 5 + .../AccessKeyTable/HostNameTableCell.test.tsx | 97 +++++++++++++++++++ .../AccessKeyTable/HostNameTableCell.tsx | 17 ++-- 3 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 packages/manager/.changeset/pr-10596-tests-1718806691101.md create mode 100644 packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx diff --git a/packages/manager/.changeset/pr-10596-tests-1718806691101.md b/packages/manager/.changeset/pr-10596-tests-1718806691101.md new file mode 100644 index 00000000000..678714c966d --- /dev/null +++ b/packages/manager/.changeset/pr-10596-tests-1718806691101.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Unit test coverage - HostNameTableCell ([#10596](https://github.com/linode/manager/pull/10596)) diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx new file mode 100644 index 00000000000..9ef213cf387 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx @@ -0,0 +1,97 @@ +import '@testing-library/jest-dom'; +import { waitFor } from '@testing-library/react'; +import React from 'react'; + +import { objectStorageKeyFactory, regionFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { HostNameTableCell } from './HostNameTableCell'; + +describe('HostNameTableCell', () => { + it('should render "None" when there are no regions', () => { + const storageKeyData = objectStorageKeyFactory.build({ + regions: [], + }); + const { getByText } = renderWithTheme( + + ); + + expect(getByText('None')).toBeInTheDocument(); + }); + + test('should render "Regions/S3 Hostnames" cell when there are regions', async () => { + const region = regionFactory.build({ + capabilities: ['Object Storage'], + id: 'us-east', + label: 'Newark, NJ', + }); + const storageKeyData = objectStorageKeyFactory.build({ + regions: [ + { + id: 'us-east', + s3_endpoint: 'alpha.test.com', + }, + ], + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + const { findByText } = renderWithTheme( + + ); + + const hostname = await findByText('Newark, NJ: alpha.test.com'); + + await waitFor(() => expect(hostname).toBeInTheDocument()); + }); + test('should render all "Regions/S3 Hostnames" in the cell when there are multiple regions', async () => { + const region = regionFactory.build({ + capabilities: ['Object Storage'], + id: 'us-east', + label: 'Newark, NJ', + }); + const storageKeyData = objectStorageKeyFactory.build({ + regions: [ + { + id: 'us-east', + s3_endpoint: 'alpha.test.com', + }, + { + id: 'us-south', + s3_endpoint: 'alpha.test.com', + }, + ], + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + const { findByText } = renderWithTheme( + + ); + const hostname = await findByText('Newark, NJ: alpha.test.com'); + const moreButton = await findByText(/and\s+1\s+more\.\.\./); + await waitFor(() => expect(hostname).toBeInTheDocument()); + + await expect(moreButton).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx index e5fb3ce88db..3bfbd4faf08 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx @@ -1,7 +1,3 @@ -import { - ObjectStorageKey, - RegionS3EndpointAndID, -} from '@linode/api-v4/lib/object-storage'; import { styled } from '@mui/material/styles'; import React from 'react'; @@ -11,6 +7,11 @@ import { TableCell } from 'src/components/TableCell'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { getRegionsByRegionId } from 'src/utilities/regions'; +import type { + ObjectStorageKey, + RegionS3EndpointAndID, +} from '@linode/api-v4/lib/object-storage'; + type Props = { setHostNames: (hostNames: RegionS3EndpointAndID[]) => void; setShowHostNamesDrawers: (show: boolean) => void; @@ -31,14 +32,14 @@ export const HostNameTableCell = ({ if (!regionsLookup || !regionsData || !regions || regions.length === 0) { return None; } + const label = regionsLookup[storageKeyData.regions[0].id]?.label; + const s3Endpoint = storageKeyData?.regions[0]?.s3_endpoint; return ( - {`${regionsLookup[storageKeyData.regions[0].id].label}: ${ - storageKeyData?.regions[0]?.s3_endpoint - } `} + {label}: {s3Endpoint} {storageKeyData?.regions?.length === 1 && ( - + )} {storageKeyData.regions.length > 1 && ( Date: Thu, 20 Jun 2024 14:17:40 -0400 Subject: [PATCH 105/163] fix: [M3-8193] - Check account access for disabling add tags button (#10583) Co-authored-by: Jaalah Ramos --- .../pr-10583-fixed-1718375225734.md | 5 +++ .../src/components/TagCell/TagCell.test.tsx | 41 +++++++++++++++++++ .../src/components/TagCell/TagCell.tsx | 8 +++- .../Linodes/LinodeEntityDetailFooter.tsx | 10 ++++- 4 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-10583-fixed-1718375225734.md create mode 100644 packages/manager/src/components/TagCell/TagCell.test.tsx diff --git a/packages/manager/.changeset/pr-10583-fixed-1718375225734.md b/packages/manager/.changeset/pr-10583-fixed-1718375225734.md new file mode 100644 index 00000000000..9692eb933c3 --- /dev/null +++ b/packages/manager/.changeset/pr-10583-fixed-1718375225734.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Users must be an unrestricted User in order to add or modify tags on Linodes ([#10583](https://github.com/linode/manager/pull/10583)) diff --git a/packages/manager/src/components/TagCell/TagCell.test.tsx b/packages/manager/src/components/TagCell/TagCell.test.tsx new file mode 100644 index 00000000000..63bfc371a12 --- /dev/null +++ b/packages/manager/src/components/TagCell/TagCell.test.tsx @@ -0,0 +1,41 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { TagCell } from './TagCell'; + +describe('TagCell Component', () => { + const tags = ['tag1', 'tag2']; + const updateTags = vi.fn(() => Promise.resolve()); + + describe('Disabled States', () => { + it('does not allow adding a new tag when disabled', async () => { + const { getByTestId } = renderWithTheme( + + ); + const disabledButton = getByTestId('Button'); + expect(disabledButton).toHaveAttribute('aria-disabled', 'true'); + }); + + it('should display the tooltip if disabled and tooltipText is true', async () => { + const { getByTestId } = renderWithTheme( + + ); + const disabledButton = getByTestId('Button'); + expect(disabledButton).toBeInTheDocument(); + + fireEvent.mouseOver(disabledButton); + + await waitFor(() => { + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + }); + + expect( + screen.getByText( + 'You must be an unrestricted User in order to add or modify tags on Linodes.' + ) + ).toBeVisible(); + }); + }); +}); diff --git a/packages/manager/src/components/TagCell/TagCell.tsx b/packages/manager/src/components/TagCell/TagCell.tsx index 7340707d0a9..9226281fee9 100644 --- a/packages/manager/src/components/TagCell/TagCell.tsx +++ b/packages/manager/src/components/TagCell/TagCell.tsx @@ -1,7 +1,6 @@ import MoreHoriz from '@mui/icons-material/MoreHoriz'; import { styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; -import { SxProps } from '@mui/system'; import * as React from 'react'; import { IconButton } from 'src/components/IconButton'; @@ -13,6 +12,8 @@ import { StyledPlusIcon, StyledTagButton } from '../Button/StyledTagButton'; import { CircleProgress } from '../CircleProgress'; import { AddTag } from './AddTag'; +import type { SxProps } from '@mui/system'; + export interface TagCellProps { /** * Disable adding or deleting tags. @@ -83,6 +84,11 @@ export const TagCell = (props: TagCellProps) => { const AddButton = (props: { panel?: boolean }) => ( } diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx index 30d8a7313f2..db1b6ce6be0 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailFooter.tsx @@ -4,6 +4,7 @@ import { useSnackbar } from 'notistack'; import * as React from 'react'; import { TagCell } from 'src/components/TagCell/TagCell'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useLinodeUpdateMutation } from 'src/queries/linodes/linodes'; import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -16,8 +17,8 @@ import { sxLastListItem, sxListItemFirstChild, } from './LinodeEntityDetail.styles'; -import { LinodeHandlers } from './LinodesLanding/LinodesLanding'; +import type { LinodeHandlers } from './LinodesLanding/LinodesLanding'; import type { Linode } from '@linode/api-v4/lib/linodes/types'; import type { TypographyProps } from 'src/components/Typography'; @@ -59,6 +60,11 @@ export const LinodeEntityDetailFooter = React.memo((props: FooterProps) => { openTagDrawer, } = props; + const isReadOnlyAccountAccess = useRestrictedGlobalGrantCheck({ + globalGrantType: 'account_access', + permittedGrantLevel: 'read_write', + }); + const { mutateAsync: updateLinode } = useLinodeUpdateMutation(linodeId); const { enqueueSnackbar } = useSnackbar(); @@ -157,7 +163,7 @@ export const LinodeEntityDetailFooter = React.memo((props: FooterProps) => { sx={{ width: '100%', }} - disabled={isLinodesGrantReadOnly} + disabled={isLinodesGrantReadOnly || isReadOnlyAccountAccess} listAllTags={openTagDrawer} tags={linodeTags} updateTags={updateTags} From 352c2b9cd177dae071954ecadbbd15452be635ba Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 20 Jun 2024 15:08:56 -0400 Subject: [PATCH 106/163] upcoming: [M3-8270] - Add Distributed Icon to ImageSelects for distributed compatible images (#10592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add distributed icon to image selects * add label to MSW * Added changeset: Add Distributed Icon to ImageSelects for distributed compatible images * add unit test to v2 * unit unit tests for old ImageSelect 🙄 --------- Co-authored-by: Banks Nussman --- ...r-10592-upcoming-features-1718725835055.md | 5 ++ .../components/ImageSelect/ImageOption.tsx | 73 ++++++++----------- .../ImageSelect/ImageSelect.test.tsx | 4 + .../components/ImageSelect/ImageSelect.tsx | 6 +- .../ImageSelectv2/ImageOptionv2.test.tsx | 11 +++ .../ImageSelectv2/ImageOptionv2.tsx | 22 +++++- packages/manager/src/mocks/serverHandlers.ts | 6 ++ 7 files changed, 79 insertions(+), 48 deletions(-) create mode 100644 packages/manager/.changeset/pr-10592-upcoming-features-1718725835055.md diff --git a/packages/manager/.changeset/pr-10592-upcoming-features-1718725835055.md b/packages/manager/.changeset/pr-10592-upcoming-features-1718725835055.md new file mode 100644 index 00000000000..87e05abd317 --- /dev/null +++ b/packages/manager/.changeset/pr-10592-upcoming-features-1718725835055.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Distributed Icon to ImageSelects for distributed compatible images ([#10592](https://github.com/linode/manager/pull/10592)) diff --git a/packages/manager/src/components/ImageSelect/ImageOption.tsx b/packages/manager/src/components/ImageSelect/ImageOption.tsx index 2180c9a9e81..b1da36086ea 100644 --- a/packages/manager/src/components/ImageSelect/ImageOption.tsx +++ b/packages/manager/src/components/ImageSelect/ImageOption.tsx @@ -1,15 +1,19 @@ import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; -import { OptionProps } from 'react-select'; import { makeStyles } from 'tss-react/mui'; +import DistributedRegionIcon from 'src/assets/icons/entityIcons/distributed-region.svg'; import { Box } from 'src/components/Box'; -import { Item } from 'src/components/EnhancedSelect'; import { Option } from 'src/components/EnhancedSelect/components/Option'; -import { TooltipIcon } from 'src/components/TooltipIcon'; import { useFlags } from 'src/hooks/useFlags'; +import { Stack } from '../Stack'; +import { Tooltip } from '../Tooltip'; + +import type { ImageItem } from './ImageSelect'; +import type { Theme } from '@mui/material/styles'; +import type { OptionProps } from 'react-select'; + const useStyles = makeStyles()((theme: Theme) => ({ distroIcon: { fontSize: '1.8em', @@ -33,8 +37,10 @@ const useStyles = makeStyles()((theme: Theme) => ({ '& g': { fill: theme.name === 'dark' ? 'white' : '#888f91', }, - display: 'flex', - padding: `2px !important`, // Revisit use of important when we refactor the Select component + display: 'flex !important', + flexDirection: 'row', + justifyContent: 'space-between', + padding: '2px 8px !important', // Revisit use of important when we refactor the Select component }, selected: { '& g': { @@ -43,11 +49,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); -interface ImageItem extends Item { - className?: string; - isCloudInitCompatible: boolean; -} - interface ImageOptionProps extends OptionProps { data: ImageItem; } @@ -59,48 +60,32 @@ export const ImageOption = (props: ImageOptionProps) => { return ( ); }; - -const sxCloudInitTooltipIcon = { - '& svg': { - height: 20, - width: 20, - }, - '&:hover': { - color: 'inherit', - }, - color: 'inherit', - marginLeft: 'auto', - padding: 0, - paddingRight: 1, -}; diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx index 25e88889795..664525d5383 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx @@ -34,6 +34,7 @@ describe('imagesToGroupedItems', () => { className: 'fl-tux', created: '2022-10-20T14:05:30', isCloudInitCompatible: false, + isDistributedCompatible: false, label: 'Slackware 14.1', value: 'private/4', }, @@ -41,6 +42,7 @@ describe('imagesToGroupedItems', () => { className: 'fl-tux', created: '2022-10-20T14:05:30', isCloudInitCompatible: false, + isDistributedCompatible: false, label: 'Slackware 14.1', value: 'private/5', }, @@ -72,6 +74,7 @@ describe('imagesToGroupedItems', () => { className: 'fl-tux', created: '2017-06-16T20:02:29', isCloudInitCompatible: false, + isDistributedCompatible: false, label: 'Debian 9 (deprecated)', value: 'private/6', }, @@ -79,6 +82,7 @@ describe('imagesToGroupedItems', () => { className: 'fl-tux', created: '2017-06-16T20:02:29', isCloudInitCompatible: false, + isDistributedCompatible: false, label: 'Debian 9 (deprecated)', value: 'private/7', }, diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.tsx index e4fbc8e9bac..5b320a4d7a7 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.tsx @@ -21,10 +21,11 @@ import { distroIcons } from '../DistributionIcon'; export type Variant = 'all' | 'private' | 'public'; -interface ImageItem extends Item { +export interface ImageItem extends Item { className: string; created: string; isCloudInitCompatible: boolean; + isDistributedCompatible: boolean; } interface ImageSelectProps { @@ -111,6 +112,9 @@ export const imagesToGroupedItems = (images: Image[]) => { : `fl-tux`, created, isCloudInitCompatible: capabilities?.includes('cloud-init'), + isDistributedCompatible: capabilities?.includes( + 'distributed-images' + ), // Add suffix 'deprecated' to the image at end of life. label: differenceInMonths > 0 ? `${label} (deprecated)` : label, diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx index f74e3570601..33923a9f889 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx @@ -36,4 +36,15 @@ describe('ImageOptionv2', () => { getByLabelText('This image is compatible with cloud-init.') ).toBeVisible(); }); + it('renders a distributed icon if image has the "distributed-images" capability', () => { + const image = imageFactory.build({ capabilities: ['distributed-images'] }); + + const { getByLabelText } = renderWithTheme( + + ); + + expect( + getByLabelText('This image is compatible with distributed regions.') + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx index 4f38225e331..d8ceb098d02 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx @@ -1,6 +1,7 @@ import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'; import React from 'react'; +import DistributedRegionIcon from 'src/assets/icons/entityIcons/distributed-region.svg'; import { useFlags } from 'src/hooks/useFlags'; import { SelectedIcon } from '../Autocomplete/Autocomplete.styles'; @@ -21,15 +22,30 @@ export const ImageOptionv2 = ({ image, isSelected, listItemProps }: Props) => { const flags = useFlags(); return ( -
  • - +
  • + {image.label} - + + + {image.capabilities.includes('distributed-images') && ( + +
    + +
    +
    + )} {flags.metadata && image.capabilities.includes('cloud-init') && ( diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 1503e8f7e16..6be553f766c 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -640,9 +640,15 @@ export const handlers = [ type: 'automatic', }); const publicImages = imageFactory.buildList(4, { is_public: true }); + const distributedImage = imageFactory.build({ + capabilities: ['cloud-init', 'distributed-images'], + label: 'distributed-image', + regions: [{ region: 'us-east', status: 'available' }], + }); const images = [ cloudinitCompatableDistro, cloudinitCompatableImage, + distributedImage, ...automaticImages, ...privateImages, ...publicImages, From 3e9c2c3396e473867acba7ddc98c42d5a415ad0a Mon Sep 17 00:00:00 2001 From: Hussain Khalil <122488130+hkhalil-akamai@users.noreply.github.com> Date: Fri, 21 Jun 2024 10:10:19 -0400 Subject: [PATCH 107/163] upcoming: [M3-8015] - Update Images Landing table (#10545) * Saving progress... * Add compatibility, total size and image id columns * Add onManageRegions to image action menu * Add unit tests for ImagesLanding * Add mock regions image and improve conditional column rendering * remove extraneous changes * Add more unit tests * Added changeset: Update Images Landing table * Suppress typechecker warnings * Prevent crashing when `.regions` field is not present * Remove sorting on image ID * MSW: respect image type filter * New RegionsList component and tests * Image Id -> Image ID * Hide columns in md breakpoint * Re-organize Images directory * Fix unit test * Add `imageServiceGen2`feature flag --- packages/api-v4/src/images/types.ts | 2 +- ...r-10545-upcoming-features-1718402597165.md | 5 + .../manager/src/dev-tools/FeatureFlagTool.tsx | 6 +- packages/manager/src/featureFlags.ts | 1 + .../Images/ImagesCreate/ImageCreate.tsx | 2 +- .../Images/{ => ImagesCreate}/ImageUpload.tsx | 6 +- .../{ => ImagesCreate}/ImageUpload.utils.ts | 0 .../ImageUploadCLIDialog.test.tsx | 0 .../ImageUploadCLIDialog.tsx | 0 .../EditImageDrawer.test.tsx | 0 .../{ => ImagesLanding}/EditImageDrawer.tsx | 24 +- .../Images/ImagesLanding/ImageRow.test.tsx | 93 +++++++ .../Images/{ => ImagesLanding}/ImageRow.tsx | 68 ++++- .../{ => ImagesLanding}/ImagesActionMenu.tsx | 18 +- .../ImagesLanding/ImagesLanding.test.tsx | 255 ++++++++++++++++++ .../{ => ImagesLanding}/ImagesLanding.tsx | 92 ++++--- .../ImagesLandingEmptyState.tsx | 0 .../ImagesLandingEmptyStateData.ts | 0 .../RebuildImageDrawer.test.tsx | 0 .../RebuildImageDrawer.tsx | 12 +- .../Images/ImagesLanding/RegionsList.test.tsx | 43 +++ .../Images/ImagesLanding/RegionsList.tsx | 31 +++ .../manager/src/features/Images/index.tsx | 2 +- packages/manager/src/mocks/serverHandlers.ts | 23 +- 24 files changed, 614 insertions(+), 69 deletions(-) create mode 100644 packages/manager/.changeset/pr-10545-upcoming-features-1718402597165.md rename packages/manager/src/features/Images/{ => ImagesCreate}/ImageUpload.tsx (98%) rename packages/manager/src/features/Images/{ => ImagesCreate}/ImageUpload.utils.ts (100%) rename packages/manager/src/features/Images/{ => ImagesCreate}/ImageUploadCLIDialog.test.tsx (100%) rename packages/manager/src/features/Images/{ => ImagesCreate}/ImageUploadCLIDialog.tsx (100%) rename packages/manager/src/features/Images/{ => ImagesLanding}/EditImageDrawer.test.tsx (100%) rename packages/manager/src/features/Images/{ => ImagesLanding}/EditImageDrawer.tsx (87%) create mode 100644 packages/manager/src/features/Images/ImagesLanding/ImageRow.test.tsx rename packages/manager/src/features/Images/{ => ImagesLanding}/ImageRow.tsx (65%) rename packages/manager/src/features/Images/{ => ImagesLanding}/ImagesActionMenu.tsx (81%) create mode 100644 packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx rename packages/manager/src/features/Images/{ => ImagesLanding}/ImagesLanding.tsx (90%) rename packages/manager/src/features/Images/{ => ImagesLanding}/ImagesLandingEmptyState.tsx (100%) rename packages/manager/src/features/Images/{ => ImagesLanding}/ImagesLandingEmptyStateData.ts (100%) rename packages/manager/src/features/Images/{ => ImagesLanding}/RebuildImageDrawer.test.tsx (100%) rename packages/manager/src/features/Images/{ => ImagesLanding}/RebuildImageDrawer.tsx (89%) create mode 100644 packages/manager/src/features/Images/ImagesLanding/RegionsList.test.tsx create mode 100644 packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index 48be8aff12a..e25fb28f9a2 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -4,7 +4,7 @@ export type ImageStatus = | 'deleted' | 'pending_upload'; -type ImageCapabilities = 'cloud-init' | 'distributed-images'; +export type ImageCapabilities = 'cloud-init' | 'distributed-images'; type ImageType = 'manual' | 'automatic'; diff --git a/packages/manager/.changeset/pr-10545-upcoming-features-1718402597165.md b/packages/manager/.changeset/pr-10545-upcoming-features-1718402597165.md new file mode 100644 index 00000000000..3651a31e1e8 --- /dev/null +++ b/packages/manager/.changeset/pr-10545-upcoming-features-1718402597165.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Update Images Landing table ([#10545](https://github.com/linode/manager/pull/10545)) diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index dfc7908ec46..74986c22644 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -4,11 +4,12 @@ import * as React from 'react'; import { useDispatch } from 'react-redux'; import withFeatureFlagProvider from 'src/containers/withFeatureFlagProvider.container'; -import { FlagSet, Flags } from 'src/featureFlags'; -import { Dispatch } from 'src/hooks/types'; import { useFlags } from 'src/hooks/useFlags'; import { setMockFeatureFlags } from 'src/store/mockFeatureFlags'; import { getStorage, setStorage } from 'src/utilities/storage'; + +import type { FlagSet, Flags } from 'src/featureFlags'; +import type { Dispatch } from 'src/hooks/types'; const MOCK_FEATURE_FLAGS_STORAGE_KEY = 'devTools/mock-feature-flags'; /** @@ -24,6 +25,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'disableLargestGbPlans', label: 'Disable Largest GB Plans' }, { flag: 'eventMessagesV2', label: 'Event Messages V2' }, { flag: 'gecko2', label: 'Gecko' }, + { flag: 'imageServiceGen2', label: 'Image Service Gen2' }, { flag: 'linodeCreateRefactor', label: 'Linode Create v2' }, { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 811f3b66097..07a945affe9 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -73,6 +73,7 @@ export interface Flags { gecko: boolean; // @TODO gecko: delete this after next release gecko2: GaFeatureFlag; gpuv2: gpuV2; + imageServiceGen2: boolean; ipv6Sharing: boolean; linodeCreateRefactor: boolean; linodeCreateWithFirewall: boolean; diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx index fafc8614c04..d1bf1d2b064 100644 --- a/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageCreate.tsx @@ -6,7 +6,7 @@ import { NavTab, NavTabs } from 'src/components/NavTabs/NavTabs'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; const ImageUpload = React.lazy(() => - import('../ImageUpload').then((module) => ({ default: module.ImageUpload })) + import('./ImageUpload').then((module) => ({ default: module.ImageUpload })) ); const CreateImageTab = React.lazy(() => diff --git a/packages/manager/src/features/Images/ImageUpload.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx similarity index 98% rename from packages/manager/src/features/Images/ImageUpload.tsx rename to packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx index 0d64ef0b88c..bfef25f2a31 100644 --- a/packages/manager/src/features/Images/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx @@ -38,15 +38,15 @@ import { setPendingUpload } from 'src/store/pendingUpload'; import { getGDPRDetails } from 'src/utilities/formatRegion'; import { readableBytes } from 'src/utilities/unitConversions'; -import { EUAgreementCheckbox } from '../Account/Agreements/EUAgreementCheckbox'; -import { getRestrictedResourceText } from '../Account/utils'; +import { EUAgreementCheckbox } from '../../Account/Agreements/EUAgreementCheckbox'; +import { getRestrictedResourceText } from '../../Account/utils'; import { ImageUploadSchema, recordImageAnalytics } from './ImageUpload.utils'; import { ImageUploadFormData, ImageUploadNavigationState, } from './ImageUpload.utils'; import { ImageUploadCLIDialog } from './ImageUploadCLIDialog'; -import { uploadImageFile } from './requests'; +import { uploadImageFile } from '../requests'; import type { AxiosError, AxiosProgressEvent } from 'axios'; diff --git a/packages/manager/src/features/Images/ImageUpload.utils.ts b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.utils.ts similarity index 100% rename from packages/manager/src/features/Images/ImageUpload.utils.ts rename to packages/manager/src/features/Images/ImagesCreate/ImageUpload.utils.ts diff --git a/packages/manager/src/features/Images/ImageUploadCLIDialog.test.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.test.tsx similarity index 100% rename from packages/manager/src/features/Images/ImageUploadCLIDialog.test.tsx rename to packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.test.tsx diff --git a/packages/manager/src/features/Images/ImageUploadCLIDialog.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.tsx similarity index 100% rename from packages/manager/src/features/Images/ImageUploadCLIDialog.tsx rename to packages/manager/src/features/Images/ImagesCreate/ImageUploadCLIDialog.tsx diff --git a/packages/manager/src/features/Images/EditImageDrawer.test.tsx b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx similarity index 100% rename from packages/manager/src/features/Images/EditImageDrawer.test.tsx rename to packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.test.tsx diff --git a/packages/manager/src/features/Images/EditImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx similarity index 87% rename from packages/manager/src/features/Images/EditImageDrawer.tsx rename to packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx index eaa00d8f4f1..582a7738462 100644 --- a/packages/manager/src/features/Images/EditImageDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx @@ -1,5 +1,4 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { APIError, Image, UpdateImagePayload } from '@linode/api-v4'; import { updateImageSchema } from '@linode/validation'; import * as React from 'react'; import { Controller, useForm } from 'react-hook-form'; @@ -9,24 +8,28 @@ import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; +import { usePrevious } from 'src/hooks/usePrevious'; import { useUpdateImageMutation } from 'src/queries/images'; -import { useImageAndLinodeGrantCheck } from './utils'; +import { useImageAndLinodeGrantCheck } from '../utils'; + +import type { APIError, Image, UpdateImagePayload } from '@linode/api-v4'; interface Props { image: Image | undefined; onClose: () => void; - open: boolean; } export const EditImageDrawer = (props: Props) => { - const { image, onClose, open } = props; + const { image, onClose } = props; const { canCreateImage } = useImageAndLinodeGrantCheck(); + // Prevent content from disappearing when closing drawer + const prevImage = usePrevious(image); const defaultValues = { - description: image?.description ?? undefined, - label: image?.label, - tags: image?.tags, + description: image?.description ?? prevImage?.description ?? undefined, + label: image?.label ?? prevImage?.label, + tags: image?.tags ?? prevImage?.tags, }; const { @@ -75,7 +78,12 @@ export const EditImageDrawer = (props: Props) => { }); return ( - + {!canCreateImage && ( mockMatchMedia()); + +describe('Image Table Row', () => { + const image = imageFactory.build({ + capabilities: ['cloud-init', 'distributed-images'], + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + + const handlers: Handlers = { + onCancelFailed: vi.fn(), + onDelete: vi.fn(), + onDeploy: vi.fn(), + onEdit: vi.fn(), + onManageRegions: vi.fn(), + onRestore: vi.fn(), + onRetry: vi.fn(), + }; + + it('should render an image row', async () => { + const { getAllByText, getByLabelText, getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + // Check to see if the row rendered some data + getByText(image.label); + getAllByText('Ready'); + getAllByText((text) => text.includes(image.regions[0].region)); + getAllByText('+1'); + getAllByText('Cloud-init, Distributed'); + expect(getAllByText('1500 MB').length).toBe(2); + getAllByText(image.id); + + // Open action menu + const actionMenu = getByLabelText(`Action menu for Image ${image.label}`); + await userEvent.click(actionMenu); + + getByText('Edit'); + getByText('Manage Regions'); + getByText('Deploy to New Linode'); + getByText('Rebuild an Existing Linode'); + getByText('Delete'); + }); + + it('calls handlers when performing actions', async () => { + const { getByLabelText, getByText } = renderWithTheme( + wrapWithTableBody( + + ) + ); + + // Open action menu + const actionMenu = getByLabelText(`Action menu for Image ${image.label}`); + await userEvent.click(actionMenu); + + await userEvent.click(getByText('Edit')); + expect(handlers.onEdit).toBeCalledWith(image); + + await userEvent.click(getByText('Manage Regions')); + expect(handlers.onManageRegions).toBeCalledWith(image); + + await userEvent.click(getByText('Deploy to New Linode')); + expect(handlers.onDeploy).toBeCalledWith(image.id); + + await userEvent.click(getByText('Rebuild an Existing Linode')); + expect(handlers.onRestore).toBeCalledWith(image); + + await userEvent.click(getByText('Delete')); + expect(handlers.onDelete).toBeCalledWith( + image.label, + image.id, + image.status + ); + }); +}); diff --git a/packages/manager/src/features/Images/ImageRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx similarity index 65% rename from packages/manager/src/features/Images/ImageRow.tsx rename to packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx index 605475be3c0..bd3e50581e2 100644 --- a/packages/manager/src/features/Images/ImageRow.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRow.tsx @@ -1,4 +1,3 @@ -import { Event, Image } from '@linode/api-v4'; import * as React from 'react'; import { Hidden } from 'src/components/Hidden'; @@ -9,23 +8,47 @@ import { useProfile } from 'src/queries/profile/profile'; import { capitalizeAllWords } from 'src/utilities/capitalize'; import { formatDate } from 'src/utilities/formatDate'; -import { Handlers, ImagesActionMenu } from './ImagesActionMenu'; +import { ImagesActionMenu } from './ImagesActionMenu'; +import { RegionsList } from './RegionsList'; + +import type { Handlers } from './ImagesActionMenu'; +import type { Event, Image, ImageCapabilities } from '@linode/api-v4'; + +const capabilityMap: Record = { + 'cloud-init': 'Cloud-init', + 'distributed-images': 'Distributed', +}; interface Props { event?: Event; handlers: Handlers; image: Image; + multiRegionsEnabled?: boolean; // TODO Image Service v2: delete after GA } const ImageRow = (props: Props) => { - const { event, image } = props; + const { event, handlers, image, multiRegionsEnabled } = props; - const { created, expiry, id, label, size, status } = image; + const { + capabilities, + created, + expiry, + id, + label, + regions, + size, + status, + total_size, + } = image; const { data: profile } = useProfile(); const isFailed = status === 'pending_upload' && event?.status === 'failed'; + const compatibilitiesList = multiRegionsEnabled + ? capabilities.map((capability) => capabilityMap[capability]).join(', ') + : ''; + const getStatusForImage = (status: string) => { switch (status) { case 'creating': @@ -63,15 +86,41 @@ const ImageRow = (props: Props) => { {label} {status ? {getStatusForImage(status)} : null} + + {multiRegionsEnabled && ( + <> + + + {regions && regions.length > 0 && ( + handlers.onManageRegions?.(image)} + regions={regions} + /> + )} + + + + {compatibilitiesList} + + + )} + + {getSizeForImage(size, status, event?.status)} + + {multiRegionsEnabled && ( + + + {getSizeForImage(total_size, status, event?.status)} + + + )} + {formatDate(created, { timezone: profile?.timezone, })} - - {getSizeForImage(size, status, event?.status)} - {expiry ? ( @@ -81,6 +130,11 @@ const ImageRow = (props: Props) => { ) : null} + {multiRegionsEnabled && ( + + {id} + + )} diff --git a/packages/manager/src/features/Images/ImagesActionMenu.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx similarity index 81% rename from packages/manager/src/features/Images/ImagesActionMenu.tsx rename to packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx index d50b994a04f..41ea6d1b519 100644 --- a/packages/manager/src/features/Images/ImagesActionMenu.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesActionMenu.tsx @@ -1,13 +1,16 @@ -import { Event, Image, ImageStatus } from '@linode/api-v4'; import * as React from 'react'; -import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; + +import type { Event, Image, ImageStatus } from '@linode/api-v4'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; export interface Handlers { onCancelFailed?: (imageID: string) => void; onDelete?: (label: string, imageID: string, status?: ImageStatus) => void; onDeploy?: (imageID: string) => void; onEdit?: (image: Image) => void; + onManageRegions?: (image: Image) => void; onRestore?: (image: Image) => void; onRetry?: ( imageID: string, @@ -32,6 +35,7 @@ export const ImagesActionMenu = (props: Props) => { onDelete, onDeploy, onEdit, + onManageRegions, onRestore, onRetry, } = handlers; @@ -60,6 +64,15 @@ export const ImagesActionMenu = (props: Props) => { ? 'Image is not yet available for use.' : undefined, }, + ...(onManageRegions + ? [ + { + disabled: isDisabled, + onClick: () => onManageRegions(image), + title: 'Manage Regions', + }, + ] + : []), { disabled: isDisabled, onClick: () => onDeploy?.(id), @@ -91,6 +104,7 @@ export const ImagesActionMenu = (props: Props) => { onCancelFailed, onEdit, image, + onManageRegions, onDeploy, onRestore, onDelete, diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx new file mode 100644 index 00000000000..1a9601dcfc6 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.test.tsx @@ -0,0 +1,255 @@ +import { waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import { imageFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; + +import ImagesLanding from './ImagesLanding'; + +const mockHistory = { + push: vi.fn(), + replace: vi.fn(), +}; + +// Mock useHistory +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useHistory: vi.fn(() => mockHistory), + }; +}); + +beforeAll(() => mockMatchMedia()); + +const loadingTestId = 'circle-progress'; + +describe('Images Landing Table', () => { + it('should render images landing table with items', async () => { + server.use( + http.get('*/images', () => { + const images = imageFactory.buildList(3, { + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { getAllByText, getByTestId } = renderWithTheme(, { + flags: { imageServiceGen2: true }, + }); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Two tables should render + getAllByText('Custom Images'); + getAllByText('Recovery Images'); + + // Static text and table column headers + expect(getAllByText('Image').length).toBe(2); + expect(getAllByText('Status').length).toBe(2); + expect(getAllByText('Region(s)').length).toBe(1); + expect(getAllByText('Compatibility').length).toBe(1); + expect(getAllByText('Size').length).toBe(2); + expect(getAllByText('Total Size').length).toBe(1); + expect(getAllByText('Created').length).toBe(2); + expect(getAllByText('Image ID').length).toBe(1); + }); + + it('should render custom images empty state', async () => { + server.use( + http.get('*/images', ({ request }) => { + return HttpResponse.json( + makeResourcePage( + request.headers.get('x-filter')?.includes('automatic') + ? [imageFactory.build({ type: 'automatic' })] + : [] + ) + ); + }) + ); + + const { getByTestId, getByText } = renderWithTheme(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + expect(getByText('No Custom Images to display.')).toBeInTheDocument(); + }); + + it('should render automatic images empty state', async () => { + server.use( + http.get('*/images', ({ request }) => { + return HttpResponse.json( + makeResourcePage( + request.headers.get('x-filter')?.includes('manual') + ? [imageFactory.build({ type: 'manual' })] + : [] + ) + ); + }) + ); + + const { getByTestId, getByText } = renderWithTheme(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + expect(getByText('No Recovery Images to display.')).toBeInTheDocument(); + }); + + it('should render images landing empty state', async () => { + server.use( + http.get('*/images', () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByTestId, getByText } = renderWithTheme(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + expect( + getByText((text) => text.includes('Store your own custom Linux images')) + ).toBeInTheDocument(); + }); + + it('should allow opening the Edit Image drawer', async () => { + const images = imageFactory.buildList(3, { + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + server.use( + http.get('*/images', () => { + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { getAllByLabelText, getByTestId, getByText } = renderWithTheme( + + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Open action menu + const actionMenu = getAllByLabelText( + `Action menu for Image ${images[0].label}` + )[0]; + await userEvent.click(actionMenu); + + await userEvent.click(getByText('Edit')); + + getByText('Edit Image'); + }); + + it('should allow opening the Restore Image drawer', async () => { + const images = imageFactory.buildList(3, { + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + server.use( + http.get('*/images', () => { + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { getAllByLabelText, getByTestId, getByText } = renderWithTheme( + + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Open action menu + const actionMenu = getAllByLabelText( + `Action menu for Image ${images[0].label}` + )[0]; + await userEvent.click(actionMenu); + + await userEvent.click(getByText('Rebuild an Existing Linode')); + + getByText('Restore from Image'); + }); + + it('should allow deploying to a new Linode', async () => { + const images = imageFactory.buildList(3, { + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + server.use( + http.get('*/images', () => { + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { getAllByLabelText, getByTestId, getByText } = renderWithTheme( + + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Open action menu + const actionMenu = getAllByLabelText( + `Action menu for Image ${images[0].label}` + )[0]; + await userEvent.click(actionMenu); + + await userEvent.click(getByText('Deploy to New Linode')); + expect(mockHistory.push).toBeCalledWith({ + pathname: '/linodes/create/', + search: `?type=Images&imageID=${images[0].id}`, + state: { selectedImageId: images[0].id }, + }); + }); + + it('should allow deleting an image', async () => { + const images = imageFactory.buildList(3, { + regions: [ + { region: 'us-east', status: 'available' }, + { region: 'us-southeast', status: 'pending' }, + ], + }); + server.use( + http.get('*/images', () => { + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { getAllByLabelText, getByTestId, getByText } = renderWithTheme( + + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // Open action menu + const actionMenu = getAllByLabelText( + `Action menu for Image ${images[0].label}` + )[0]; + await userEvent.click(actionMenu); + + await userEvent.click(getByText('Delete')); + + getByText(`Delete Image ${images[0].label}`); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx similarity index 90% rename from packages/manager/src/features/Images/ImagesLanding.tsx rename to packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 37a58e4152a..c3cc58de087 100644 --- a/packages/manager/src/features/Images/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -28,6 +28,7 @@ import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading' import { TableSortCell } from 'src/components/TableSortCell'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; +import { useFlags } from 'src/hooks/useFlags'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { @@ -42,11 +43,11 @@ import { } from 'src/queries/images'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; +import { getEventsForImages } from '../utils'; import { EditImageDrawer } from './EditImageDrawer'; import ImageRow from './ImageRow'; import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; import { RebuildImageDrawer } from './RebuildImageDrawer'; -import { getEventsForImages } from './utils'; import type { Handlers as ImageHandlers } from './ImagesActionMenu'; import type { Image, ImageStatus } from '@linode/api-v4'; @@ -90,6 +91,7 @@ export const ImagesLanding = () => { const { classes } = useStyles(); const history = useHistory(); const { enqueueSnackbar } = useSnackbar(); + const flags = useFlags(); const location = useLocation(); const queryParams = new URLSearchParams(location.search); const imageLabelFromParam = queryParams.get(searchQueryKey) ?? ''; @@ -198,19 +200,25 @@ export const ImagesLanding = () => { imageEvents ); + // TODO Image Service V2: delete after GA + const multiRegionsEnabled = + (flags.imageServiceGen2 && + manualImages?.data.some((image) => image.regions?.length)) ?? + false; + // Automatic images with the associated events tied in. const automaticImagesEvents = getEventsForImages( automaticImages?.data ?? [], imageEvents ); - const [selectedImage, setSelectedImage] = React.useState(); - - const [editDrawerOpen, setEditDrawerOpen] = React.useState(false); - - const [rebuildDrawerOpen, setRebuildDrawerOpen] = React.useState( - false - ); + const [ + // @ts-expect-error This will be unused until the regions drawer is implemented + manageRegionsDrawerImage, + setManageRegionsDrawerImage, + ] = React.useState(); + const [editDrawerImage, setEditDrawerImage] = React.useState(); + const [rebuildDrawerImage, setRebuildDrawerImage] = React.useState(); const [dialog, setDialogState] = React.useState( defaultDialogState @@ -296,16 +304,6 @@ export const ImagesLanding = () => { queryClient.invalidateQueries(imageQueries.paginated._def); }; - const openForEdit = (image: Image) => { - setSelectedImage(image); - setEditDrawerOpen(true); - }; - - const openForRestore = (image: Image) => { - setSelectedImage(image); - setRebuildDrawerOpen(true); - }; - const deployNewLinode = (imageID: string) => { history.push({ pathname: `/linodes/create/`, @@ -347,8 +345,11 @@ export const ImagesLanding = () => { onCancelFailed: onCancelFailedClick, onDelete: openDialog, onDeploy: deployNewLinode, - onEdit: openForEdit, - onRestore: openForRestore, + onEdit: setEditDrawerImage, + onManageRegions: multiRegionsEnabled + ? setManageRegionsDrawerImage + : undefined, + onRestore: setRebuildDrawerImage, onRetry: onRetryClick, }; @@ -392,7 +393,7 @@ export const ImagesLanding = () => { } const noManualImages = ( - + ); const noAutomaticImages = ( @@ -458,7 +459,30 @@ export const ImagesLanding = () => { Status - + {multiRegionsEnabled && ( + <> + + Region(s) + + + Compatibility + + + )} + + Size + + {multiRegionsEnabled && ( + + Total Size + + )} + { Created - - Size - + {multiRegionsEnabled && ( + + Image ID + + )} @@ -487,6 +508,7 @@ export const ImagesLanding = () => { handlers={handlers} image={manualImage} key={manualImage.id} + multiRegionsEnabled={multiRegionsEnabled} /> )) : noManualImages} @@ -574,14 +596,12 @@ export const ImagesLanding = () => { /> setEditDrawerOpen(false)} - open={editDrawerOpen} + image={editDrawerImage} + onClose={() => setEditDrawerImage(undefined)} /> setRebuildDrawerOpen(false)} - open={rebuildDrawerOpen} + image={rebuildDrawerImage} + onClose={() => setRebuildDrawerImage(undefined)} /> void; - open?: boolean; } export const RebuildImageDrawer = (props: Props) => { - const { image, onClose, open } = props; + const { image, onClose } = props; const history = useHistory(); const { @@ -51,7 +51,7 @@ export const RebuildImageDrawer = (props: Props) => { {formState.errors.root?.message && ( diff --git a/packages/manager/src/features/Images/ImagesLanding/RegionsList.test.tsx b/packages/manager/src/features/Images/ImagesLanding/RegionsList.test.tsx new file mode 100644 index 00000000000..ea58d15f6dc --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/RegionsList.test.tsx @@ -0,0 +1,43 @@ +import userEvent from '@testing-library/user-event'; +import * as React from 'react'; + +import 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { RegionsList } from './RegionsList'; + +describe('RegionsList', () => { + it('should render a single region', async () => { + const { findByText } = renderWithTheme( + + ); + + // Should initially fallback to region id + await findByText('us-east'); + await findByText('Newark, NJ'); + }); + + it('should allow expanding to view multiple regions', async () => { + const manageRegions = vi.fn(); + + const { findByRole, findByText } = renderWithTheme( + + ); + + await findByText((text) => text.includes('Newark, NJ')); + const expand = await findByRole('button'); + expect(expand).toHaveTextContent('+1'); + + await userEvent.click(expand); + expect(manageRegions).toBeCalled(); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx b/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx new file mode 100644 index 00000000000..e17785ea634 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/RegionsList.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { StyledLinkButton } from 'src/components/Button/StyledLinkButton'; +import { Typography } from 'src/components/Typography'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import type { ImageRegion } from '@linode/api-v4'; + +interface Props { + onManageRegions: () => void; + regions: ImageRegion[]; +} + +export const RegionsList = ({ onManageRegions, regions }: Props) => { + const { data: regionsData } = useRegionsQuery(); + + return ( + + {regionsData?.find((region) => region.id == regions[0].region)?.label ?? + regions[0].region} + {regions.length > 1 && ( + <> + ,{' '} + + +{regions.length - 1} + + + )} + + ); +}; diff --git a/packages/manager/src/features/Images/index.tsx b/packages/manager/src/features/Images/index.tsx index 4f294a76b29..91767da9302 100644 --- a/packages/manager/src/features/Images/index.tsx +++ b/packages/manager/src/features/Images/index.tsx @@ -4,7 +4,7 @@ import { Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; -const ImagesLanding = React.lazy(() => import('./ImagesLanding')); +const ImagesLanding = React.lazy(() => import('./ImagesLanding/ImagesLanding')); const ImageCreate = React.lazy( () => import('./ImagesCreate/ImageCreateContainer') ); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 6be553f766c..f0ad8f8fc6d 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -607,7 +607,7 @@ export const handlers = [ http.get('*/regions', async () => { return HttpResponse.json(makeResourcePage(regions)); }), - http.get('*/images', async () => { + http.get('*/images', async ({ request }) => { const privateImages = imageFactory.buildList(5, { status: 'available', type: 'manual', @@ -627,6 +627,16 @@ export const handlers = [ status: 'available', type: 'manual', }); + const multiRegionsImage = imageFactory.build({ + id: 'multi-regions-test-image', + label: 'multi-regions-test-image', + regions: [ + { region: 'us-southeast', status: 'available' }, + { region: 'us-east', status: 'pending' }, + ], + status: 'available', + type: 'manual', + }); const creatingImages = imageFactory.buildList(2, { status: 'creating', type: 'manual', @@ -648,6 +658,7 @@ export const handlers = [ const images = [ cloudinitCompatableDistro, cloudinitCompatableImage, + multiRegionsImage, distributedImage, ...automaticImages, ...privateImages, @@ -655,7 +666,15 @@ export const handlers = [ ...pendingImages, ...creatingImages, ]; - return HttpResponse.json(makeResourcePage(images)); + return HttpResponse.json( + makeResourcePage( + images.filter((image) => + request.headers.get('x-filter')?.includes('manual') + ? image.type == 'manual' + : image.type == 'automatic' + ) + ) + ); }), http.get('*/linode/types', () => { From eb2bfddfbabc8eac989bddf28550e3f787ae04a4 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Fri, 21 Jun 2024 09:34:38 -0500 Subject: [PATCH 108/163] fix: [M3-8274] - Fix parsing issue causing in Kubernetes Version field (#10597) * unit test coverage for HostNameTableCell * Revert "unit test coverage for HostNameTableCell" This reverts commit b274baf67e27d79fd4e764607ded7c5aa755ee8b. * fix: [M3-8274] - Fix parsing issue causing in Kubernetes Version field * code cleanup * Added changeset: fix: [M3-8274] - Fix parsing issue causing in Kubernetes Version field * PR - feedback - @jdamore-linode * Update sort-by.ts * Update packages/manager/src/features/Kubernetes/kubeUtils.test.ts Co-authored-by: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> * Update packages/manager/src/utilities/sort-by.test.ts Co-authored-by: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> * PR -feedback - @abailly-akamai --------- Co-authored-by: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> --- .../pr-10597-fixed-1718893685492.md | 5 ++ .../src/features/Kubernetes/kubeUtils.test.ts | 40 +++++++++++++- .../src/features/Kubernetes/kubeUtils.ts | 40 +++++++++++--- .../manager/src/utilities/sort-by.test.ts | 38 +++++++++++++ packages/manager/src/utilities/sort-by.ts | 53 +++++++++++++++++++ 5 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 packages/manager/.changeset/pr-10597-fixed-1718893685492.md create mode 100644 packages/manager/src/utilities/sort-by.test.ts diff --git a/packages/manager/.changeset/pr-10597-fixed-1718893685492.md b/packages/manager/.changeset/pr-10597-fixed-1718893685492.md new file mode 100644 index 00000000000..bce06d11bc7 --- /dev/null +++ b/packages/manager/.changeset/pr-10597-fixed-1718893685492.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +fix: [M3-8274] - Fix parsing issue causing in Kubernetes Version field ([#10597](https://github.com/linode/manager/pull/10597)) diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts index 30f67b917be..35619fcce5f 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.test.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.test.ts @@ -5,7 +5,10 @@ import { } from 'src/factories'; import { extendType } from 'src/utilities/extendType'; -import { getTotalClusterMemoryCPUAndStorage } from './kubeUtils'; +import { + getLatestVersion, + getTotalClusterMemoryCPUAndStorage, +} from './kubeUtils'; describe('helper functions', () => { const badPool = nodePoolFactory.build({ @@ -64,4 +67,39 @@ describe('helper functions', () => { }); }); }); + describe('getLatestVersion', () => { + it('should return the correct latest version from a list of versions', () => { + const versions = [ + { label: '1.00', value: '1.00' }, + { label: '1.10', value: '1.10' }, + { label: '2.00', value: '2.00' }, + ]; + const result = getLatestVersion(versions); + expect(result).toEqual({ label: '2.00', value: '2.00' }); + }); + + it('should handle latest version minor version correctly', () => { + const versions = [ + { label: '1.22', value: '1.22' }, + { label: '1.23', value: '1.23' }, + { label: '1.30', value: '1.30' }, + ]; + const result = getLatestVersion(versions); + expect(result).toEqual({ label: '1.30', value: '1.30' }); + }); + it('should handle latest patch version correctly', () => { + const versions = [ + { label: '1.22', value: '1.30' }, + { label: '1.23', value: '1.15' }, + { label: '1.30', value: '1.50.1' }, + { label: '1.30', value: '1.50' }, + ]; + const result = getLatestVersion(versions); + expect(result).toEqual({ label: '1.50.1', value: '1.50.1' }); + }); + it('should return default fallback value when called with empty versions', () => { + const result = getLatestVersion([]); + expect(result).toEqual({ label: '', value: '' }); + }); + }); }); diff --git a/packages/manager/src/features/Kubernetes/kubeUtils.ts b/packages/manager/src/features/Kubernetes/kubeUtils.ts index 9b91404a0ec..0189a5b2dfd 100644 --- a/packages/manager/src/features/Kubernetes/kubeUtils.ts +++ b/packages/manager/src/features/Kubernetes/kubeUtils.ts @@ -1,3 +1,5 @@ +import { sortByVersion } from 'src/utilities/sort-by'; + import type { Account } from '@linode/api-v4/lib/account'; import type { KubeNodePoolResponse, @@ -6,7 +8,6 @@ import type { } from '@linode/api-v4/lib/kubernetes'; import type { Region } from '@linode/api-v4/lib/regions'; import type { ExtendedType } from 'src/utilities/extendType'; - export const nodeWarning = `We recommend a minimum of 3 nodes in each Node Pool to avoid downtime during upgrades and maintenance.`; export const nodesDeletionWarning = `All nodes will be deleted and new nodes will be created to replace them.`; export const localStorageWarning = `Any local storage (such as \u{2019}hostPath\u{2019} volumes) will be erased.`; @@ -111,15 +112,40 @@ export const getKubeHighAvailability = ( }; }; +/** + * Retrieves the latest version from an array of version objects. + * + * This function sorts an array of objects containing version information and returns the object + * with the highest version number. The sorting is performed in ascending order based on the + * `value` property of each object, and the last element of the sorted array, which represents + * the latest version, is returned. + * + * @param {{label: string, value: string}[]} versions - An array of objects with `label` and `value` + * properties where `value` is a version string. + * @returns {{label: string, value: string}} Returns the object with the highest version number. + * If the array is empty, returns an default fallback object. + * + * @example + * // Returns the latest version object + * getLatestVersion([ + * { label: 'Version 1.1', value: '1.1' }, + * { label: 'Version 2.0', value: '2.0' } + * ]); + * // Output: { label: '2.0', value: '2.0' } + */ export const getLatestVersion = ( versions: { label: string; value: string }[] -) => { - const versionsNumbersArray: number[] = []; +): { label: string; value: string } => { + const sortedVersions = versions.sort((a, b) => { + return sortByVersion(a.value, b.value, 'asc'); + }); + + const latestVersion = sortedVersions.pop(); - for (const element of versions) { - versionsNumbersArray.push(parseFloat(element.value)); + if (!latestVersion) { + // Return a default fallback object + return { label: '', value: '' }; } - const latestVersionValue = Math.max.apply(null, versionsNumbersArray); - return { label: `${latestVersionValue}`, value: `${latestVersionValue}` }; + return { label: `${latestVersion.value}`, value: `${latestVersion.value}` }; }; diff --git a/packages/manager/src/utilities/sort-by.test.ts b/packages/manager/src/utilities/sort-by.test.ts new file mode 100644 index 00000000000..dd1760f419e --- /dev/null +++ b/packages/manager/src/utilities/sort-by.test.ts @@ -0,0 +1,38 @@ +import { sortByVersion } from './sort-by'; + +describe('sortByVersion', () => { + it('should identify the later major version as greater', () => { + const result = sortByVersion('2.0.0', '1.0.0', 'asc'); + expect(result).toBeGreaterThan(0); + }); + + it('should identify the later minor version as greater', () => { + const result = sortByVersion('1.2.0', '1.1.0', 'asc'); + expect(result).toBeGreaterThan(0); + }); + + it('should identify the later patch version as greater', () => { + const result = sortByVersion('1.1.2', '1.1.1', 'asc'); + expect(result).toBeGreaterThan(0); + }); + + it('should identify the later minor version with differing number of digits', () => { + const result = sortByVersion('1.30', '1.3', 'asc'); + expect(result).toBeGreaterThan(0); + }); + + it('should return negative when the first version is earlier in ascending order', () => { + const result = sortByVersion('1.0.0', '2.0.0', 'asc'); + expect(result).toBeLessThan(0); + }); + + it('should return positive when the first version is earlier in descending order', () => { + const result = sortByVersion('1.0.0', '2.0.0', 'desc'); + expect(result).toBeGreaterThan(0); + }); + + it('should return zero when versions are equal', () => { + const result = sortByVersion('1.2.3', '1.2.3', 'asc'); + expect(result).toEqual(0); + }); +}); diff --git a/packages/manager/src/utilities/sort-by.ts b/packages/manager/src/utilities/sort-by.ts index 51753c9e809..8724e5ffbd5 100644 --- a/packages/manager/src/utilities/sort-by.ts +++ b/packages/manager/src/utilities/sort-by.ts @@ -45,3 +45,56 @@ export const sortByArrayLength = (a: any[], b: any[], order: SortOrder) => { return order === 'asc' ? result : -result; }; + +/** + * Compares two semantic version strings based on the specified order. + * + * This function splits each version string into its constituent parts (major, minor, patch), + * compares them numerically, and returns a positive number, zero, or a negative number + * based on the specified sorting order. If components are missing in either version, + * they are treated as zero. + * + * @param {string} a - The first version string to compare. + * @param {string} b - The second version string to compare. + * @param {SortOrder} order - The order to sort by, can be 'asc' for ascending or 'desc' for descending. + * @returns {number} Returns a positive number if version `a` is greater than `b` according to the sort order, + * zero if they are equal, and a negative number if `b` is greater than `a`. + * + * @example + * // returns a positive number + * sortByVersion('1.2.3', '1.2.2', 'asc'); + * + * @example + * // returns zero + * sortByVersion('1.2.3', '1.2.3', 'asc'); + * + * @example + * // returns a negative number + * sortByVersion('1.2.3', '1.2.4', 'asc'); + */ + +export const sortByVersion = ( + a: string, + b: string, + order: SortOrder +): number => { + const aParts = a.split('.'); + const bParts = b.split('.'); + + const result = (() => { + for (let i = 0; i < Math.max(aParts.length, bParts.length); i += 1) { + // If one version has a part and another doesn't (e.g. 3.1 vs 3.1.1), + // treat the missing part as 0. + const aNumber = Number(aParts[i]) || 0; + const bNumber = Number(bParts[i]) || 0; + const diff = aNumber - bNumber; + + if (diff !== 0) { + return diff; + } + } + return 0; + })(); + + return order === 'asc' ? result : -result; +}; From 3a245e86b03ba2c6976dcaf96e38d8af0fa01446 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Fri, 21 Jun 2024 09:53:31 -0500 Subject: [PATCH 109/163] Cloud version 1.122.0 and API v4 version 0.120.0 --- .../pr-10505-added-1716400052332.md | 5 -- .../pr-10514-added-1716499303668.md | 5 -- .../pr-10566-added-1718123757956.md | 5 -- packages/api-v4/CHANGELOG.md | 9 +++ packages/api-v4/package.json | 2 +- .../pr-10477-tests-1717448262964.md | 5 -- .../pr-10505-changed-1716400104404.md | 5 -- .../pr-10514-tech-stories-1716499244555.md | 5 -- ...r-10539-upcoming-features-1717414240425.md | 5 -- .../pr-10544-added-1717530161965.md | 5 -- .../pr-10546-tests-1717604850412.md | 5 -- ...r-10546-upcoming-features-1717603180090.md | 5 -- ...r-10549-upcoming-features-1718120005131.md | 5 -- .../pr-10550-tech-stories-1718069370794.md | 5 -- .../pr-10552-tests-1717702940797.md | 5 -- .../pr-10554-changed-1718122854647.md | 5 -- ...r-10555-upcoming-features-1717778847157.md | 5 -- .../pr-10556-tech-stories-1717782967942.md | 5 -- .../pr-10559-tech-stories-1718119071574.md | 5 -- .../pr-10561-tech-stories-1718047853944.md | 5 -- .../pr-10562-tests-1718047447893.md | 5 -- ...r-10564-upcoming-features-1718118654107.md | 5 -- .../pr-10566-changed-1718123674587.md | 5 -- .../pr-10568-tech-stories-1718139515455.md | 5 -- .../pr-10569-fixed-1718134339100.md | 5 -- .../pr-10570-added-1718308378096.md | 5 -- .../pr-10573-tech-stories-1718213403351.md | 5 -- .../pr-10574-tests-1718222529272.md | 5 -- ...r-10576-upcoming-features-1718227220058.md | 5 -- ...r-10578-upcoming-features-1718295945732.md | 5 -- .../pr-10581-tests-1718308219716.md | 5 -- .../pr-10582-changed-1718310753730.md | 5 -- .../pr-10583-fixed-1718375225734.md | 5 -- .../pr-10585-tests-1718380830458.md | 5 -- ...r-10586-upcoming-features-1718384912084.md | 5 -- .../pr-10587-fixed-1718643059797.md | 5 -- .../pr-10588-fixed-1718731923572.md | 5 -- .../pr-10590-fixed-1718722141069.md | 5 -- .../pr-10591-tests-1718746383365.md | 5 -- ...r-10592-upcoming-features-1718725835055.md | 5 -- .../pr-10596-tests-1718806691101.md | 5 -- packages/manager/CHANGELOG.md | 60 +++++++++++++++++++ packages/manager/package.json | 2 +- 43 files changed, 71 insertions(+), 197 deletions(-) delete mode 100644 packages/api-v4/.changeset/pr-10505-added-1716400052332.md delete mode 100644 packages/api-v4/.changeset/pr-10514-added-1716499303668.md delete mode 100644 packages/api-v4/.changeset/pr-10566-added-1718123757956.md delete mode 100644 packages/manager/.changeset/pr-10477-tests-1717448262964.md delete mode 100644 packages/manager/.changeset/pr-10505-changed-1716400104404.md delete mode 100644 packages/manager/.changeset/pr-10514-tech-stories-1716499244555.md delete mode 100644 packages/manager/.changeset/pr-10539-upcoming-features-1717414240425.md delete mode 100644 packages/manager/.changeset/pr-10544-added-1717530161965.md delete mode 100644 packages/manager/.changeset/pr-10546-tests-1717604850412.md delete mode 100644 packages/manager/.changeset/pr-10546-upcoming-features-1717603180090.md delete mode 100644 packages/manager/.changeset/pr-10549-upcoming-features-1718120005131.md delete mode 100644 packages/manager/.changeset/pr-10550-tech-stories-1718069370794.md delete mode 100644 packages/manager/.changeset/pr-10552-tests-1717702940797.md delete mode 100644 packages/manager/.changeset/pr-10554-changed-1718122854647.md delete mode 100644 packages/manager/.changeset/pr-10555-upcoming-features-1717778847157.md delete mode 100644 packages/manager/.changeset/pr-10556-tech-stories-1717782967942.md delete mode 100644 packages/manager/.changeset/pr-10559-tech-stories-1718119071574.md delete mode 100644 packages/manager/.changeset/pr-10561-tech-stories-1718047853944.md delete mode 100644 packages/manager/.changeset/pr-10562-tests-1718047447893.md delete mode 100644 packages/manager/.changeset/pr-10564-upcoming-features-1718118654107.md delete mode 100644 packages/manager/.changeset/pr-10566-changed-1718123674587.md delete mode 100644 packages/manager/.changeset/pr-10568-tech-stories-1718139515455.md delete mode 100644 packages/manager/.changeset/pr-10569-fixed-1718134339100.md delete mode 100644 packages/manager/.changeset/pr-10570-added-1718308378096.md delete mode 100644 packages/manager/.changeset/pr-10573-tech-stories-1718213403351.md delete mode 100644 packages/manager/.changeset/pr-10574-tests-1718222529272.md delete mode 100644 packages/manager/.changeset/pr-10576-upcoming-features-1718227220058.md delete mode 100644 packages/manager/.changeset/pr-10578-upcoming-features-1718295945732.md delete mode 100644 packages/manager/.changeset/pr-10581-tests-1718308219716.md delete mode 100644 packages/manager/.changeset/pr-10582-changed-1718310753730.md delete mode 100644 packages/manager/.changeset/pr-10583-fixed-1718375225734.md delete mode 100644 packages/manager/.changeset/pr-10585-tests-1718380830458.md delete mode 100644 packages/manager/.changeset/pr-10586-upcoming-features-1718384912084.md delete mode 100644 packages/manager/.changeset/pr-10587-fixed-1718643059797.md delete mode 100644 packages/manager/.changeset/pr-10588-fixed-1718731923572.md delete mode 100644 packages/manager/.changeset/pr-10590-fixed-1718722141069.md delete mode 100644 packages/manager/.changeset/pr-10591-tests-1718746383365.md delete mode 100644 packages/manager/.changeset/pr-10592-upcoming-features-1718725835055.md delete mode 100644 packages/manager/.changeset/pr-10596-tests-1718806691101.md diff --git a/packages/api-v4/.changeset/pr-10505-added-1716400052332.md b/packages/api-v4/.changeset/pr-10505-added-1716400052332.md deleted file mode 100644 index c9a609f8b06..00000000000 --- a/packages/api-v4/.changeset/pr-10505-added-1716400052332.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Added ---- - -New endpoint for LKE HA types used in pricing ([#10505](https://github.com/linode/manager/pull/10505)) diff --git a/packages/api-v4/.changeset/pr-10514-added-1716499303668.md b/packages/api-v4/.changeset/pr-10514-added-1716499303668.md deleted file mode 100644 index a6853cb0f79..00000000000 --- a/packages/api-v4/.changeset/pr-10514-added-1716499303668.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Added ---- - -UpdateImagePayload type ([#10514](https://github.com/linode/manager/pull/10514)) diff --git a/packages/api-v4/.changeset/pr-10566-added-1718123757956.md b/packages/api-v4/.changeset/pr-10566-added-1718123757956.md deleted file mode 100644 index 33f278dce49..00000000000 --- a/packages/api-v4/.changeset/pr-10566-added-1718123757956.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Added ---- - -New endpoint for `network-transfer/prices`` ([#10566](https://github.com/linode/manager/pull/10566)) diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 002c7d2f8d3..487a58ca1bc 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,12 @@ +## [2024-06-24] - v0.120.0 + + +### Added: + +- New endpoint for LKE HA types used in pricing ([#10505](https://github.com/linode/manager/pull/10505)) +- UpdateImagePayload type ([#10514](https://github.com/linode/manager/pull/10514)) +- New endpoint for `network-transfer/prices`` ([#10566](https://github.com/linode/manager/pull/10566)) + ## [2024-06-10] - v0.119.0 diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index ffd3e5944d4..99d23eaffec 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.119.0", + "version": "0.120.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/manager/.changeset/pr-10477-tests-1717448262964.md b/packages/manager/.changeset/pr-10477-tests-1717448262964.md deleted file mode 100644 index 19fd7139b94..00000000000 --- a/packages/manager/.changeset/pr-10477-tests-1717448262964.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Cypress integration test to add SSH key via Profile page ([#10477](https://github.com/linode/manager/pull/10477)) diff --git a/packages/manager/.changeset/pr-10505-changed-1716400104404.md b/packages/manager/.changeset/pr-10505-changed-1716400104404.md deleted file mode 100644 index e5c959f550c..00000000000 --- a/packages/manager/.changeset/pr-10505-changed-1716400104404.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@linode/manager': Changed ---- - -Use dynamic HA pricing with `lke/types` endpoint ([#10505](https://github.com/linode/manager/pull/10505)) diff --git a/packages/manager/.changeset/pr-10514-tech-stories-1716499244555.md b/packages/manager/.changeset/pr-10514-tech-stories-1716499244555.md deleted file mode 100644 index 1af3abaf117..00000000000 --- a/packages/manager/.changeset/pr-10514-tech-stories-1716499244555.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Refactor and clean up ImagesDrawer ([#10514](https://github.com/linode/manager/pull/10514)) diff --git a/packages/manager/.changeset/pr-10539-upcoming-features-1717414240425.md b/packages/manager/.changeset/pr-10539-upcoming-features-1717414240425.md deleted file mode 100644 index 69f5bc6565a..00000000000 --- a/packages/manager/.changeset/pr-10539-upcoming-features-1717414240425.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Resources MultiSelect component in cloudpulse global filters view ([#10539](https://github.com/linode/manager/pull/10539)) diff --git a/packages/manager/.changeset/pr-10544-added-1717530161965.md b/packages/manager/.changeset/pr-10544-added-1717530161965.md deleted file mode 100644 index f1a78d8ef7e..00000000000 --- a/packages/manager/.changeset/pr-10544-added-1717530161965.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Informational notice about capturing an image from a Linode in a distributed compute region ([#10544](https://github.com/linode/manager/pull/10544)) diff --git a/packages/manager/.changeset/pr-10546-tests-1717604850412.md b/packages/manager/.changeset/pr-10546-tests-1717604850412.md deleted file mode 100644 index b27e1dccd3e..00000000000 --- a/packages/manager/.changeset/pr-10546-tests-1717604850412.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add assertions regarding Disk Encryption info banner to lke-landing-page.spec.ts ([#10546](https://github.com/linode/manager/pull/10546)) diff --git a/packages/manager/.changeset/pr-10546-upcoming-features-1717603180090.md b/packages/manager/.changeset/pr-10546-upcoming-features-1717603180090.md deleted file mode 100644 index fe944b48194..00000000000 --- a/packages/manager/.changeset/pr-10546-upcoming-features-1717603180090.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add Disk Encryption info banner to Kubernetes landing page ([#10546](https://github.com/linode/manager/pull/10546)) diff --git a/packages/manager/.changeset/pr-10549-upcoming-features-1718120005131.md b/packages/manager/.changeset/pr-10549-upcoming-features-1718120005131.md deleted file mode 100644 index 16a668a7064..00000000000 --- a/packages/manager/.changeset/pr-10549-upcoming-features-1718120005131.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add Disk Encryption section to Linode Rebuild modal ([#10549](https://github.com/linode/manager/pull/10549)) diff --git a/packages/manager/.changeset/pr-10550-tech-stories-1718069370794.md b/packages/manager/.changeset/pr-10550-tech-stories-1718069370794.md deleted file mode 100644 index 2c8cfaa986c..00000000000 --- a/packages/manager/.changeset/pr-10550-tech-stories-1718069370794.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Event Messages Refactor: progress events ([#10550](https://github.com/linode/manager/pull/10550)) diff --git a/packages/manager/.changeset/pr-10552-tests-1717702940797.md b/packages/manager/.changeset/pr-10552-tests-1717702940797.md deleted file mode 100644 index 9c353c069e2..00000000000 --- a/packages/manager/.changeset/pr-10552-tests-1717702940797.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add Placement Group navigation integration tests ([#10552](https://github.com/linode/manager/pull/10552)) diff --git a/packages/manager/.changeset/pr-10554-changed-1718122854647.md b/packages/manager/.changeset/pr-10554-changed-1718122854647.md deleted file mode 100644 index 87be53cbdf0..00000000000 --- a/packages/manager/.changeset/pr-10554-changed-1718122854647.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Rename to 'Choose a Distribution' to 'Choose an OS' in Linode Create flow ([#10554](https://github.com/linode/manager/pull/10554)) diff --git a/packages/manager/.changeset/pr-10555-upcoming-features-1717778847157.md b/packages/manager/.changeset/pr-10555-upcoming-features-1717778847157.md deleted file mode 100644 index 607765079fb..00000000000 --- a/packages/manager/.changeset/pr-10555-upcoming-features-1717778847157.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Obj fix for crashing accesskey page when relevant customer tags are not added ([#10555](https://github.com/linode/manager/pull/10555)) diff --git a/packages/manager/.changeset/pr-10556-tech-stories-1717782967942.md b/packages/manager/.changeset/pr-10556-tech-stories-1717782967942.md deleted file mode 100644 index 6d5356bdca8..00000000000 --- a/packages/manager/.changeset/pr-10556-tech-stories-1717782967942.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -NodeBalancer Query Key Factory ([#10556](https://github.com/linode/manager/pull/10556)) diff --git a/packages/manager/.changeset/pr-10559-tech-stories-1718119071574.md b/packages/manager/.changeset/pr-10559-tech-stories-1718119071574.md deleted file mode 100644 index 32ba2b829c0..00000000000 --- a/packages/manager/.changeset/pr-10559-tech-stories-1718119071574.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Query Key Factory for Domains ([#10559](https://github.com/linode/manager/pull/10559)) diff --git a/packages/manager/.changeset/pr-10561-tech-stories-1718047853944.md b/packages/manager/.changeset/pr-10561-tech-stories-1718047853944.md deleted file mode 100644 index 08c24258388..00000000000 --- a/packages/manager/.changeset/pr-10561-tech-stories-1718047853944.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Upgrade Vitest and related dependencies to 1.6.0 ([#10561](https://github.com/linode/manager/pull/10561)) diff --git a/packages/manager/.changeset/pr-10562-tests-1718047447893.md b/packages/manager/.changeset/pr-10562-tests-1718047447893.md deleted file mode 100644 index 99142c23e77..00000000000 --- a/packages/manager/.changeset/pr-10562-tests-1718047447893.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Improve Cypress test suite compatibility against alternative environments ([#10562](https://github.com/linode/manager/pull/10562)) diff --git a/packages/manager/.changeset/pr-10564-upcoming-features-1718118654107.md b/packages/manager/.changeset/pr-10564-upcoming-features-1718118654107.md deleted file mode 100644 index b8d3556c204..00000000000 --- a/packages/manager/.changeset/pr-10564-upcoming-features-1718118654107.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Linode Create v2 - Handle side-effects when changing the Region ([#10564](https://github.com/linode/manager/pull/10564)) diff --git a/packages/manager/.changeset/pr-10566-changed-1718123674587.md b/packages/manager/.changeset/pr-10566-changed-1718123674587.md deleted file mode 100644 index 4d7abc2ec23..00000000000 --- a/packages/manager/.changeset/pr-10566-changed-1718123674587.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Use dynamic outbound transfer pricing with `network-transfer/prices` endpoint ([#10566](https://github.com/linode/manager/pull/10566)) diff --git a/packages/manager/.changeset/pr-10568-tech-stories-1718139515455.md b/packages/manager/.changeset/pr-10568-tech-stories-1718139515455.md deleted file mode 100644 index 9fa5023e72f..00000000000 --- a/packages/manager/.changeset/pr-10568-tech-stories-1718139515455.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Query Key Factory for Firewalls ([#10568](https://github.com/linode/manager/pull/10568)) diff --git a/packages/manager/.changeset/pr-10569-fixed-1718134339100.md b/packages/manager/.changeset/pr-10569-fixed-1718134339100.md deleted file mode 100644 index 98ae6b50e0c..00000000000 --- a/packages/manager/.changeset/pr-10569-fixed-1718134339100.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Marketplace docs urls for Apache Kafka Cluster and Couchbase Cluster ([#10569](https://github.com/linode/manager/pull/10569)) diff --git a/packages/manager/.changeset/pr-10570-added-1718308378096.md b/packages/manager/.changeset/pr-10570-added-1718308378096.md deleted file mode 100644 index d8077d9974b..00000000000 --- a/packages/manager/.changeset/pr-10570-added-1718308378096.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Volume & Images landing pages search and filtering ([#10570](https://github.com/linode/manager/pull/10570)) diff --git a/packages/manager/.changeset/pr-10573-tech-stories-1718213403351.md b/packages/manager/.changeset/pr-10573-tech-stories-1718213403351.md deleted file mode 100644 index 5b209448551..00000000000 --- a/packages/manager/.changeset/pr-10573-tech-stories-1718213403351.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Update TypeScript to latest ([#10573](https://github.com/linode/manager/pull/10573)) diff --git a/packages/manager/.changeset/pr-10574-tests-1718222529272.md b/packages/manager/.changeset/pr-10574-tests-1718222529272.md deleted file mode 100644 index 3c7eadd5dbe..00000000000 --- a/packages/manager/.changeset/pr-10574-tests-1718222529272.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Improve stability of StackScripts pagination test ([#10574](https://github.com/linode/manager/pull/10574)) diff --git a/packages/manager/.changeset/pr-10576-upcoming-features-1718227220058.md b/packages/manager/.changeset/pr-10576-upcoming-features-1718227220058.md deleted file mode 100644 index b5d9b29f95d..00000000000 --- a/packages/manager/.changeset/pr-10576-upcoming-features-1718227220058.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Revise LDE copy in Linode Create flow when Distributed region is selected ([#10576](https://github.com/linode/manager/pull/10576)) diff --git a/packages/manager/.changeset/pr-10578-upcoming-features-1718295945732.md b/packages/manager/.changeset/pr-10578-upcoming-features-1718295945732.md deleted file mode 100644 index 5f2b63c140c..00000000000 --- a/packages/manager/.changeset/pr-10578-upcoming-features-1718295945732.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Update description for Add Node Pools section in LKE Create flow ([#10578](https://github.com/linode/manager/pull/10578)) diff --git a/packages/manager/.changeset/pr-10581-tests-1718308219716.md b/packages/manager/.changeset/pr-10581-tests-1718308219716.md deleted file mode 100644 index 96c0f6d70db..00000000000 --- a/packages/manager/.changeset/pr-10581-tests-1718308219716.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Fix Linode/Firewall related E2E test flake ([#10581](https://github.com/linode/manager/pull/10581)) diff --git a/packages/manager/.changeset/pr-10582-changed-1718310753730.md b/packages/manager/.changeset/pr-10582-changed-1718310753730.md deleted file mode 100644 index 22d60d40356..00000000000 --- a/packages/manager/.changeset/pr-10582-changed-1718310753730.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Link Cloud Manager README to new documentation pages ([#10582](https://github.com/linode/manager/pull/10582)) diff --git a/packages/manager/.changeset/pr-10583-fixed-1718375225734.md b/packages/manager/.changeset/pr-10583-fixed-1718375225734.md deleted file mode 100644 index 9692eb933c3..00000000000 --- a/packages/manager/.changeset/pr-10583-fixed-1718375225734.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Users must be an unrestricted User in order to add or modify tags on Linodes ([#10583](https://github.com/linode/manager/pull/10583)) diff --git a/packages/manager/.changeset/pr-10585-tests-1718380830458.md b/packages/manager/.changeset/pr-10585-tests-1718380830458.md deleted file mode 100644 index caef6949ece..00000000000 --- a/packages/manager/.changeset/pr-10585-tests-1718380830458.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Mock profile request to improve security questions test stability ([#10585](https://github.com/linode/manager/pull/10585)) diff --git a/packages/manager/.changeset/pr-10586-upcoming-features-1718384912084.md b/packages/manager/.changeset/pr-10586-upcoming-features-1718384912084.md deleted file mode 100644 index b6867f3165a..00000000000 --- a/packages/manager/.changeset/pr-10586-upcoming-features-1718384912084.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - - Linode Create v2 - Add Marketplace Searching / Filtering ([#10586](https://github.com/linode/manager/pull/10586)) diff --git a/packages/manager/.changeset/pr-10587-fixed-1718643059797.md b/packages/manager/.changeset/pr-10587-fixed-1718643059797.md deleted file mode 100644 index 1d10211f718..00000000000 --- a/packages/manager/.changeset/pr-10587-fixed-1718643059797.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -CONTRIBUTING doc page commit type list markup ([#10587](https://github.com/linode/manager/pull/10587)) diff --git a/packages/manager/.changeset/pr-10588-fixed-1718731923572.md b/packages/manager/.changeset/pr-10588-fixed-1718731923572.md deleted file mode 100644 index e12343bfa68..00000000000 --- a/packages/manager/.changeset/pr-10588-fixed-1718731923572.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -React Query Events `seen` behavior and other optimizations ([#10588](https://github.com/linode/manager/pull/10588)) diff --git a/packages/manager/.changeset/pr-10590-fixed-1718722141069.md b/packages/manager/.changeset/pr-10590-fixed-1718722141069.md deleted file mode 100644 index 2fbb1319e47..00000000000 --- a/packages/manager/.changeset/pr-10590-fixed-1718722141069.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Accessibility: Add tabindex to TextTooltip ([#10590](https://github.com/linode/manager/pull/10590)) diff --git a/packages/manager/.changeset/pr-10591-tests-1718746383365.md b/packages/manager/.changeset/pr-10591-tests-1718746383365.md deleted file mode 100644 index fe1bc7bcd75..00000000000 --- a/packages/manager/.changeset/pr-10591-tests-1718746383365.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Fix hanging unit tests ([#10591](https://github.com/linode/manager/pull/10591)) diff --git a/packages/manager/.changeset/pr-10592-upcoming-features-1718725835055.md b/packages/manager/.changeset/pr-10592-upcoming-features-1718725835055.md deleted file mode 100644 index 87e05abd317..00000000000 --- a/packages/manager/.changeset/pr-10592-upcoming-features-1718725835055.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add Distributed Icon to ImageSelects for distributed compatible images ([#10592](https://github.com/linode/manager/pull/10592)) diff --git a/packages/manager/.changeset/pr-10596-tests-1718806691101.md b/packages/manager/.changeset/pr-10596-tests-1718806691101.md deleted file mode 100644 index 678714c966d..00000000000 --- a/packages/manager/.changeset/pr-10596-tests-1718806691101.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Unit test coverage - HostNameTableCell ([#10596](https://github.com/linode/manager/pull/10596)) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index cf21e9f2632..2506d5ec552 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,66 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2024-06-24] - v1.122.0 + + +### : + +- Use dynamic HA pricing with `lke/types` endpoint ([#10505](https://github.com/linode/manager/pull/10505)) + +### Added: + +- Informational notice about capturing an image from a Linode in a distributed compute region ([#10544](https://github.com/linode/manager/pull/10544)) +- Volume & Images landing pages search and filtering ([#10570](https://github.com/linode/manager/pull/10570)) + +### Changed: + +- Rename to 'Choose a Distribution' to 'Choose an OS' in Linode Create flow ([#10554](https://github.com/linode/manager/pull/10554)) +- Use dynamic outbound transfer pricing with `network-transfer/prices` endpoint ([#10566](https://github.com/linode/manager/pull/10566)) +- Link Cloud Manager README to new documentation pages ([#10582](https://github.com/linode/manager/pull/10582)) + +### Fixed: + +- Marketplace docs urls for Apache Kafka Cluster and Couchbase Cluster ([#10569](https://github.com/linode/manager/pull/10569)) +- Users must be an unrestricted User in order to add or modify tags on Linodes ([#10583](https://github.com/linode/manager/pull/10583)) +- CONTRIBUTING doc page commit type list markup ([#10587](https://github.com/linode/manager/pull/10587)) +- React Query Events `seen` behavior and other optimizations ([#10588](https://github.com/linode/manager/pull/10588)) +- Accessibility: Add tabindex to TextTooltip ([#10590](https://github.com/linode/manager/pull/10590)) + +### Tech Stories: + +- Refactor and clean up ImagesDrawer ([#10514](https://github.com/linode/manager/pull/10514)) +- Event Messages Refactor: progress events ([#10550](https://github.com/linode/manager/pull/10550)) +- NodeBalancer Query Key Factory ([#10556](https://github.com/linode/manager/pull/10556)) +- Query Key Factory for Domains ([#10559](https://github.com/linode/manager/pull/10559)) +- Upgrade Vitest and related dependencies to 1.6.0 ([#10561](https://github.com/linode/manager/pull/10561)) +- Query Key Factory for Firewalls ([#10568](https://github.com/linode/manager/pull/10568)) +- Update TypeScript to latest ([#10573](https://github.com/linode/manager/pull/10573)) + +### Tests: + +- Cypress integration test to add SSH key via Profile page ([#10477](https://github.com/linode/manager/pull/10477)) +- Add assertions regarding Disk Encryption info banner to lke-landing-page.spec.ts ([#10546](https://github.com/linode/manager/pull/10546)) +- Add Placement Group navigation integration tests ([#10552](https://github.com/linode/manager/pull/10552)) +- Improve Cypress test suite compatibility against alternative environments ([#10562](https://github.com/linode/manager/pull/10562)) +- Improve stability of StackScripts pagination test ([#10574](https://github.com/linode/manager/pull/10574)) +- Fix Linode/Firewall related E2E test flake ([#10581](https://github.com/linode/manager/pull/10581)) +- Mock profile request to improve security questions test stability ([#10585](https://github.com/linode/manager/pull/10585)) +- Fix hanging unit tests ([#10591](https://github.com/linode/manager/pull/10591)) +- Unit test coverage - HostNameTableCell ([#10596](https://github.com/linode/manager/pull/10596)) + +### Upcoming Features: + +- Resources MultiSelect component in cloudpulse global filters view ([#10539](https://github.com/linode/manager/pull/10539)) +- Add Disk Encryption info banner to Kubernetes landing page ([#10546](https://github.com/linode/manager/pull/10546)) +- Add Disk Encryption section to Linode Rebuild modal ([#10549](https://github.com/linode/manager/pull/10549)) +- Obj fix for crashing accesskey page when relevant customer tags are not added ([#10555](https://github.com/linode/manager/pull/10555)) +- Linode Create v2 - Handle side-effects when changing the Region ([#10564](https://github.com/linode/manager/pull/10564)) +- Revise LDE copy in Linode Create flow when Distributed region is selected ([#10576](https://github.com/linode/manager/pull/10576)) +- Update description for Add Node Pools section in LKE Create flow ([#10578](https://github.com/linode/manager/pull/10578)) +- Linode Create v2 - Add Marketplace Searching / Filtering ([#10586](https://github.com/linode/manager/pull/10586)) +- Add Distributed Icon to ImageSelects for distributed compatible images ([#10592](https://github.com/linode/manager/pull/10592)) + ## [2024-06-11] - v1.121.1 ### Fixed: diff --git a/packages/manager/package.json b/packages/manager/package.json index c6e0afd6552..9e86feb11c2 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.121.1", + "version": "1.122.0", "private": true, "type": "module", "bugs": { From 33e29836fc1a874774419ba4ae83e098ba2b0d8a Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 21 Jun 2024 12:34:28 -0400 Subject: [PATCH 110/163] test: Update Object Storage tests to mock account `capabilities` as needed (#10602) * mock account capabilities as needed * Added changeset: Update Object Storage tests to mock account capabilities as needed for multi cluster --------- Co-authored-by: Banks Nussman --- .../pr-10602-tests-1718985699307.md | 5 ++ .../core/objectStorage/access-key.e2e.spec.ts | 4 ++ .../objectStorage/access-keys.smoke.spec.ts | 10 ++- .../enable-object-storage.spec.ts | 7 +- .../objectStorage/object-storage.e2e.spec.ts | 8 ++- .../object-storage.smoke.spec.ts | 71 ++++++++++++++++++- 6 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-10602-tests-1718985699307.md diff --git a/packages/manager/.changeset/pr-10602-tests-1718985699307.md b/packages/manager/.changeset/pr-10602-tests-1718985699307.md new file mode 100644 index 00000000000..82ccb0de864 --- /dev/null +++ b/packages/manager/.changeset/pr-10602-tests-1718985699307.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Update Object Storage tests to mock account capabilities as needed for multi cluster ([#10602](https://github.com/linode/manager/pull/10602)) diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts index 9d8ef3a7757..3806fb0ebb0 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts @@ -17,6 +17,8 @@ import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; +import { mockGetAccount } from 'support/intercepts/account'; +import { accountFactory } from 'src/factories'; authenticate(); describe('object storage access key end-to-end tests', () => { @@ -37,6 +39,7 @@ describe('object storage access key end-to-end tests', () => { interceptGetAccessKeys().as('getKeys'); interceptCreateAccessKey().as('createKey'); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); @@ -131,6 +134,7 @@ describe('object storage access key end-to-end tests', () => { ).then(() => { const keyLabel = randomLabel(); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts index 84d67db2cb4..f3972f56cbc 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts @@ -25,10 +25,11 @@ import { randomString, } from 'support/util/random'; import { ui } from 'support/ui'; -import { regionFactory } from 'src/factories'; +import { accountFactory, regionFactory } from 'src/factories'; import { mockGetRegions } from 'support/intercepts/regions'; import { buildArray } from 'support/util/arrays'; import { Scope } from '@linode/api-v4'; +import { mockGetAccount } from 'support/intercepts/account'; describe('object storage access keys smoke tests', () => { /* @@ -44,6 +45,7 @@ describe('object storage access keys smoke tests', () => { secret_key: randomString(39), }); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); @@ -115,6 +117,7 @@ describe('object storage access keys smoke tests', () => { secret_key: randomString(39), }); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); @@ -164,6 +167,11 @@ describe('object storage access keys smoke tests', () => { const mockRegions = [...mockRegionsObj, ...mockRegionsNoObj]; beforeEach(() => { + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage Access Key Regions'], + }) + ); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(true), }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index 76459bd7b5b..08e52e042a4 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -14,8 +14,12 @@ import { profileFactory, regionFactory, objectStorageKeyFactory, + accountFactory, } from '@src/factories'; -import { mockGetAccountSettings } from 'support/intercepts/account'; +import { + mockGetAccount, + mockGetAccountSettings, +} from 'support/intercepts/account'; import { mockCancelObjectStorage, mockCreateAccessKey, @@ -56,6 +60,7 @@ describe('Object Storage enrollment', () => { * - Confirms that consistent pricing information is shown for all regions in the enable modal. */ it('can enroll in Object Storage', () => { + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index 87cdaf3371d..be2705f89e3 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts @@ -4,9 +4,12 @@ import 'cypress-file-upload'; import { createBucket } from '@linode/api-v4/lib/object-storage'; -import { objectStorageBucketFactory } from 'src/factories'; +import { accountFactory, objectStorageBucketFactory } from 'src/factories'; import { authenticate } from 'support/api/authentication'; -import { interceptGetNetworkUtilization } from 'support/intercepts/account'; +import { + interceptGetNetworkUtilization, + mockGetAccount, +} from 'support/intercepts/account'; import { interceptCreateBucket, interceptDeleteBucket, @@ -132,6 +135,7 @@ describe('object storage end-to-end tests', () => { interceptDeleteBucket(bucketLabel, bucketCluster).as('deleteBucket'); interceptGetNetworkUtilization().as('getNetworkUtilization'); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }).as('getFeatureFlags'); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts index 261c4a10491..505ba19b880 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts @@ -23,7 +23,8 @@ import { import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel, randomString } from 'support/util/random'; import { ui } from 'support/ui'; -import { regionFactory } from 'src/factories'; +import { accountFactory, regionFactory } from 'src/factories'; +import { mockGetAccount } from 'support/intercepts/account'; describe('object storage smoke tests', () => { /* @@ -56,6 +57,11 @@ describe('object storage smoke tests', () => { objects: 0, }); + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage Access Key Regions'], + }) + ); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(true), }).as('getFeatureFlags'); @@ -160,6 +166,7 @@ describe('object storage smoke tests', () => { hostname: bucketHostname, }); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }).as('getFeatureFlags'); @@ -286,7 +293,7 @@ describe('object storage smoke tests', () => { * - Mocks existing buckets. * - Deletes mocked bucket, confirms that landing page reflects deletion. */ - it('can delete object storage bucket - smoke', () => { + it('can delete object storage bucket - smoke - Multi Cluster Disabled', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-southeast-1'; const bucketMock = objectStorageBucketFactory.build({ @@ -296,6 +303,12 @@ describe('object storage smoke tests', () => { objects: 0, }); + mockGetAccount(accountFactory.build({ capabilities: [] })); + mockAppendFeatureFlags({ + objMultiCluster: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + mockGetBuckets([bucketMock]).as('getBuckets'); mockDeleteBucket(bucketLabel, bucketCluster).as('deleteBucket'); @@ -324,4 +337,58 @@ describe('object storage smoke tests', () => { cy.wait('@deleteBucket'); cy.findByText('S3-compatible storage solution').should('be.visible'); }); + + /* + * - Tests core object storage bucket deletion flow using mocked API responses. + * - Mocks existing buckets. + * - Deletes mocked bucket, confirms that landing page reflects deletion. + */ + it('can delete object storage bucket - smoke - Multi Cluster Enabled', () => { + const bucketLabel = randomLabel(); + const bucketCluster = 'us-southeast-1'; + const bucketMock = objectStorageBucketFactory.build({ + label: bucketLabel, + cluster: bucketCluster, + hostname: `${bucketLabel}.${bucketCluster}.linodeobjects.com`, + objects: 0, + }); + + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage Access Key Regions'], + }) + ); + mockAppendFeatureFlags({ + objMultiCluster: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + + mockGetBuckets([bucketMock]).as('getBuckets'); + mockDeleteBucket(bucketLabel, bucketMock.region!).as('deleteBucket'); + + cy.visitWithLogin('/object-storage'); + cy.wait('@getBuckets'); + + cy.findByText(bucketLabel) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Delete').should('be.visible').click(); + }); + + ui.dialog + .findByTitle(`Delete Bucket ${bucketLabel}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Bucket Name').click().type(bucketLabel); + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.enabled') + .should('be.visible') + .click(); + }); + + cy.wait('@deleteBucket'); + cy.findByText('S3-compatible storage solution').should('be.visible'); + }); }); From 40ee98bfcb365628e263743ee9a94b24ee427f77 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 21 Jun 2024 12:34:28 -0400 Subject: [PATCH 111/163] test: Update Object Storage tests to mock account `capabilities` as needed (#10602) * mock account capabilities as needed * Added changeset: Update Object Storage tests to mock account capabilities as needed for multi cluster --------- Co-authored-by: Banks Nussman --- .../pr-10602-tests-1718985699307.md | 5 ++ .../core/objectStorage/access-key.e2e.spec.ts | 4 ++ .../objectStorage/access-keys.smoke.spec.ts | 10 ++- .../enable-object-storage.spec.ts | 7 +- .../objectStorage/object-storage.e2e.spec.ts | 8 ++- .../object-storage.smoke.spec.ts | 71 ++++++++++++++++++- 6 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-10602-tests-1718985699307.md diff --git a/packages/manager/.changeset/pr-10602-tests-1718985699307.md b/packages/manager/.changeset/pr-10602-tests-1718985699307.md new file mode 100644 index 00000000000..82ccb0de864 --- /dev/null +++ b/packages/manager/.changeset/pr-10602-tests-1718985699307.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Update Object Storage tests to mock account capabilities as needed for multi cluster ([#10602](https://github.com/linode/manager/pull/10602)) diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts index 9d8ef3a7757..3806fb0ebb0 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts @@ -17,6 +17,8 @@ import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; +import { mockGetAccount } from 'support/intercepts/account'; +import { accountFactory } from 'src/factories'; authenticate(); describe('object storage access key end-to-end tests', () => { @@ -37,6 +39,7 @@ describe('object storage access key end-to-end tests', () => { interceptGetAccessKeys().as('getKeys'); interceptCreateAccessKey().as('createKey'); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); @@ -131,6 +134,7 @@ describe('object storage access key end-to-end tests', () => { ).then(() => { const keyLabel = randomLabel(); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts index 84d67db2cb4..f3972f56cbc 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts @@ -25,10 +25,11 @@ import { randomString, } from 'support/util/random'; import { ui } from 'support/ui'; -import { regionFactory } from 'src/factories'; +import { accountFactory, regionFactory } from 'src/factories'; import { mockGetRegions } from 'support/intercepts/regions'; import { buildArray } from 'support/util/arrays'; import { Scope } from '@linode/api-v4'; +import { mockGetAccount } from 'support/intercepts/account'; describe('object storage access keys smoke tests', () => { /* @@ -44,6 +45,7 @@ describe('object storage access keys smoke tests', () => { secret_key: randomString(39), }); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); @@ -115,6 +117,7 @@ describe('object storage access keys smoke tests', () => { secret_key: randomString(39), }); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); @@ -164,6 +167,11 @@ describe('object storage access keys smoke tests', () => { const mockRegions = [...mockRegionsObj, ...mockRegionsNoObj]; beforeEach(() => { + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage Access Key Regions'], + }) + ); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(true), }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index 76459bd7b5b..08e52e042a4 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -14,8 +14,12 @@ import { profileFactory, regionFactory, objectStorageKeyFactory, + accountFactory, } from '@src/factories'; -import { mockGetAccountSettings } from 'support/intercepts/account'; +import { + mockGetAccount, + mockGetAccountSettings, +} from 'support/intercepts/account'; import { mockCancelObjectStorage, mockCreateAccessKey, @@ -56,6 +60,7 @@ describe('Object Storage enrollment', () => { * - Confirms that consistent pricing information is shown for all regions in the enable modal. */ it('can enroll in Object Storage', () => { + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index 87cdaf3371d..be2705f89e3 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts @@ -4,9 +4,12 @@ import 'cypress-file-upload'; import { createBucket } from '@linode/api-v4/lib/object-storage'; -import { objectStorageBucketFactory } from 'src/factories'; +import { accountFactory, objectStorageBucketFactory } from 'src/factories'; import { authenticate } from 'support/api/authentication'; -import { interceptGetNetworkUtilization } from 'support/intercepts/account'; +import { + interceptGetNetworkUtilization, + mockGetAccount, +} from 'support/intercepts/account'; import { interceptCreateBucket, interceptDeleteBucket, @@ -132,6 +135,7 @@ describe('object storage end-to-end tests', () => { interceptDeleteBucket(bucketLabel, bucketCluster).as('deleteBucket'); interceptGetNetworkUtilization().as('getNetworkUtilization'); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }).as('getFeatureFlags'); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts index 261c4a10491..505ba19b880 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts @@ -23,7 +23,8 @@ import { import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel, randomString } from 'support/util/random'; import { ui } from 'support/ui'; -import { regionFactory } from 'src/factories'; +import { accountFactory, regionFactory } from 'src/factories'; +import { mockGetAccount } from 'support/intercepts/account'; describe('object storage smoke tests', () => { /* @@ -56,6 +57,11 @@ describe('object storage smoke tests', () => { objects: 0, }); + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage Access Key Regions'], + }) + ); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(true), }).as('getFeatureFlags'); @@ -160,6 +166,7 @@ describe('object storage smoke tests', () => { hostname: bucketHostname, }); + mockGetAccount(accountFactory.build({ capabilities: [] })); mockAppendFeatureFlags({ objMultiCluster: makeFeatureFlagData(false), }).as('getFeatureFlags'); @@ -286,7 +293,7 @@ describe('object storage smoke tests', () => { * - Mocks existing buckets. * - Deletes mocked bucket, confirms that landing page reflects deletion. */ - it('can delete object storage bucket - smoke', () => { + it('can delete object storage bucket - smoke - Multi Cluster Disabled', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-southeast-1'; const bucketMock = objectStorageBucketFactory.build({ @@ -296,6 +303,12 @@ describe('object storage smoke tests', () => { objects: 0, }); + mockGetAccount(accountFactory.build({ capabilities: [] })); + mockAppendFeatureFlags({ + objMultiCluster: makeFeatureFlagData(false), + }); + mockGetFeatureFlagClientstream(); + mockGetBuckets([bucketMock]).as('getBuckets'); mockDeleteBucket(bucketLabel, bucketCluster).as('deleteBucket'); @@ -324,4 +337,58 @@ describe('object storage smoke tests', () => { cy.wait('@deleteBucket'); cy.findByText('S3-compatible storage solution').should('be.visible'); }); + + /* + * - Tests core object storage bucket deletion flow using mocked API responses. + * - Mocks existing buckets. + * - Deletes mocked bucket, confirms that landing page reflects deletion. + */ + it('can delete object storage bucket - smoke - Multi Cluster Enabled', () => { + const bucketLabel = randomLabel(); + const bucketCluster = 'us-southeast-1'; + const bucketMock = objectStorageBucketFactory.build({ + label: bucketLabel, + cluster: bucketCluster, + hostname: `${bucketLabel}.${bucketCluster}.linodeobjects.com`, + objects: 0, + }); + + mockGetAccount( + accountFactory.build({ + capabilities: ['Object Storage Access Key Regions'], + }) + ); + mockAppendFeatureFlags({ + objMultiCluster: makeFeatureFlagData(true), + }); + mockGetFeatureFlagClientstream(); + + mockGetBuckets([bucketMock]).as('getBuckets'); + mockDeleteBucket(bucketLabel, bucketMock.region!).as('deleteBucket'); + + cy.visitWithLogin('/object-storage'); + cy.wait('@getBuckets'); + + cy.findByText(bucketLabel) + .should('be.visible') + .closest('tr') + .within(() => { + cy.findByText('Delete').should('be.visible').click(); + }); + + ui.dialog + .findByTitle(`Delete Bucket ${bucketLabel}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Bucket Name').click().type(bucketLabel); + ui.buttonGroup + .findButtonByTitle('Delete') + .should('be.enabled') + .should('be.visible') + .click(); + }); + + cy.wait('@deleteBucket'); + cy.findByText('S3-compatible storage solution').should('be.visible'); + }); }); From d1263e717f883f93790c57a386f35cc80c0bca45 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Fri, 21 Jun 2024 17:08:05 -0400 Subject: [PATCH 112/163] hotfix: Fix Object Storage Object URLs (#10603) * use cluster id in object urls insted of region id * clean up slightly * add comments * improve * add changeset and version bump * revert `isFeatureEnabled` change * feedback * fix last bug --------- Co-authored-by: Banks Nussman --- packages/manager/CHANGELOG.md | 6 +++ packages/manager/package.json | 2 +- .../BucketDetail/BucketDetail.tsx | 45 +++++++++++++++++-- .../src/features/ObjectStorage/utilities.ts | 15 ++----- 4 files changed, 52 insertions(+), 16 deletions(-) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index cf21e9f2632..ac4dd7d6d8a 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2024-06-21] - v1.121.2 + +### Fixed: + +- Object Storage showing incorrect object URLs ([#10603](https://github.com/linode/manager/pull/10603)) + ## [2024-06-11] - v1.121.1 ### Fixed: diff --git a/packages/manager/package.json b/packages/manager/package.json index bd23d36d5d8..a894bf51c24 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.121.1", + "version": "1.121.2", "private": true, "type": "module", "bugs": { diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx index d44407fdbbb..a9c7a48ec5b 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx @@ -29,6 +29,8 @@ import { queryKey, updateBucket, useObjectBucketDetailsInfiniteQuery, + useObjectStorageBuckets, + useObjectStorageClusters, } from 'src/queries/objectStorage'; import { sendDownloadObjectEvent } from 'src/utilities/analytics/customEventAnalytics'; import { getQueryParamFromQueryString } from 'src/utilities/queryParams'; @@ -53,6 +55,10 @@ import { import { CreateFolderDrawer } from './CreateFolderDrawer'; import { ObjectDetailsDrawer } from './ObjectDetailsDrawer'; import ObjectTableContent from './ObjectTableContent'; +import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { useFlags } from 'src/hooks/useFlags'; +import { useAccount } from 'src/queries/account/account'; +import { useRegionsQuery } from 'src/queries/regions/regions'; interface MatchParams { bucketName: string; @@ -60,6 +66,10 @@ interface MatchParams { } export const BucketDetail = () => { + /** + * @note If `Object Storage Access Key Regions` is enabled, clusterId will actually contain + * the bucket's region id + */ const match = useRouteMatch( '/object-storage/buckets/:clusterId/:bucketName' ); @@ -70,6 +80,36 @@ export const BucketDetail = () => { const clusterId = match?.params.clusterId || ''; const prefix = getQueryParamFromQueryString(location.search, 'prefix'); const queryClient = useQueryClient(); + + const flags = useFlags(); + const { data: account } = useAccount(); + + const isObjMultiClusterEnabled = isFeatureEnabled( + 'Object Storage Access Key Regions', + Boolean(flags.objMultiCluster), + account?.capabilities ?? [] + ); + + const { data: regions } = useRegionsQuery(); + + const regionsSupportingObjectStorage = regions?.filter((region) => + region.capabilities.includes('Object Storage') + ); + + const { data: clusters } = useObjectStorageClusters(); + const { data: buckets } = useObjectStorageBuckets({ + clusters, + isObjMultiClusterEnabled, + regions: regionsSupportingObjectStorage, + }); + + const bucket = buckets?.buckets.find((bucket) => { + if (isObjMultiClusterEnabled) { + return bucket.label === bucketName && bucket.region === clusterId; + } + return bucket.label === bucketName && bucket.cluster === clusterId; + }); + const { data, error, @@ -428,9 +468,8 @@ export const BucketDetail = () => { { - const path = `${bucketName}.${clusterId}.${OBJECT_STORAGE_ROOT}/${objectName}`; - return { - absolute: 'https://' + path, - path, - }; +export const generateObjectUrl = (hostname: string, objectName: string) => { + return `https://${hostname}/${objectName}`; }; // Objects ending with a / and having a size of 0 are often used to represent From f284f8cebf75bb6b91d33563dddc38e437c30629 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Mon, 24 Jun 2024 11:33:02 -0400 Subject: [PATCH 113/163] added: [M3-8008, M3-8277] - Update invoices for JP and EU (#10606) Co-authored-by: Jaalah Ramos Co-authored-by: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> --- packages/manager/CHANGELOG.md | 2 + packages/manager/src/featureFlags.ts | 8 ++++ .../Billing/PdfGenerator/PdfGenerator.ts | 37 +++++++++++++------ 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 2506d5ec552..5b17bc8d14e 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Informational notice about capturing an image from a Linode in a distributed compute region ([#10544](https://github.com/linode/manager/pull/10544)) - Volume & Images landing pages search and filtering ([#10570](https://github.com/linode/manager/pull/10570)) +- Standard Tax Rate for JP ([#10606](https://github.com/linode/manager/pull/10606)) +- B2B Tax ID for EU ([#10606](https://github.com/linode/manager/pull/10606)) ### Changed: diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 07a945affe9..ff4ae7b05d1 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -7,6 +7,14 @@ import type { NoticeVariant } from 'src/components/Notice/Notice'; export interface TaxDetail { qi_registration?: string; tax_id: string; + tax_ids?: Record< + 'B2B' | 'B2C', + { + tax_id: string; + tax_name: string; + } + >; + tax_info?: string; tax_name: string; } diff --git a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts index 662080f614c..1acc134b281 100644 --- a/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts +++ b/packages/manager/src/features/Billing/PdfGenerator/PdfGenerator.ts @@ -1,22 +1,14 @@ -import { - Account, - Invoice, - InvoiceItem, - Payment, -} from '@linode/api-v4/lib/account'; import axios from 'axios'; import jsPDF from 'jspdf'; import { splitEvery } from 'ramda'; import { ADDRESSES } from 'src/constants'; import { reportException } from 'src/exceptionReporting'; -import { FlagSet, TaxDetail } from 'src/featureFlags'; import { formatDate } from 'src/utilities/formatDate'; import { getShouldUseAkamaiBilling } from '../billingUtils'; import AkamaiLogo from './akamai-logo.png'; import { - PdfResult, createFooter, createInvoiceItemsTable, createInvoiceTotalsTable, @@ -27,7 +19,15 @@ import { pageMargin, } from './utils'; +import type { PdfResult } from './utils'; import type { Region } from '@linode/api-v4'; +import type { + Account, + Invoice, + InvoiceItem, + Payment, +} from '@linode/api-v4/lib/account'; +import type { FlagSet, TaxDetail } from 'src/featureFlags'; const baseFont = 'helvetica'; @@ -98,17 +98,29 @@ const addLeftHeader = ( doc.setFont(baseFont, 'normal'); if (countryTax) { - addLine(`${countryTax.tax_name}: ${countryTax.tax_id}`); + const { tax_id, tax_ids, tax_name } = countryTax; + + addLine(`${tax_name}: ${tax_id}`); + + if (tax_ids?.B2B) { + const { tax_id: b2bTaxId, tax_name: b2bTaxName } = tax_ids.B2B; + addLine(`${b2bTaxName}: ${b2bTaxId}`); + } } /** - * M3-7847 Add Akamai's Japanese QI System ID to Japanese Invoices. + * [M3-7847, M3-8008] Add Akamai's Japanese QI System ID to Japanese Invoices. * Since LD automatically serves Tax data based on the user's * we can check on qi_registration field to render QI Registration. * */ if (countryTax && countryTax.qi_registration) { - const line = `QI Registration # ${countryTax.qi_registration}`; - addLine(line); + const qiRegistration = `QI Registration # ${countryTax.qi_registration}`; + addLine(qiRegistration); + } + + if (countryTax?.tax_info) { + addLine(countryTax.tax_info); } + if (provincialTax) { addLine(`${provincialTax.tax_name}: ${provincialTax.tax_id}`); } @@ -248,6 +260,7 @@ export const printInvoice = async ( * as of 2/20/2020 we have the following cases: * * VAT: Applies only to EU countries; started from 6/1/2019 and we have an EU tax id + * - [M3-8277] For EU customers, invoices will include VAT for B2C transactions and exclude VAT for B2B transactions. Both VAT numbers will be shown on the invoice template for EU countries. * GMT: Applies to both Australia and India, but we only have a tax ID for Australia. */ const hasTax = !taxes?.date ? true : convertedInvoiceDate > TaxStartDate; From 9c9a2b6f11b0204c1fc88f38f7f399b7cf88d1f9 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Mon, 24 Jun 2024 11:44:54 -0400 Subject: [PATCH 114/163] fix: [M3-8264] - Conditional invocation of useEventInfiniteQuery hook (#10584) Co-authored-by: Jaalah Ramos --- .../pr-10584-fixed-1718376872460.md | 5 +++ .../useEventNotifications.tsx | 40 ++++++++++--------- 2 files changed, 26 insertions(+), 19 deletions(-) create mode 100644 packages/manager/.changeset/pr-10584-fixed-1718376872460.md diff --git a/packages/manager/.changeset/pr-10584-fixed-1718376872460.md b/packages/manager/.changeset/pr-10584-fixed-1718376872460.md new file mode 100644 index 00000000000..586c1a96686 --- /dev/null +++ b/packages/manager/.changeset/pr-10584-fixed-1718376872460.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Potential runtime issue with conditional hook ([#10584](https://github.com/linode/manager/pull/10584)) diff --git a/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx b/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx index 9da07d66e95..1fba29828e2 100644 --- a/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx +++ b/packages/manager/src/features/NotificationCenter/NotificationData/useEventNotifications.tsx @@ -23,40 +23,42 @@ const defaultUnwantedEvents: EventAction[] = [ 'volume_update', ]; -export const useEventNotifications = (givenEvents?: Event[]) => { - const events = removeBlocklistedEvents( - givenEvents ?? useEventsInfiniteQuery().events - ); +export const useEventNotifications = (): NotificationItem[] => { + const { events: fetchedEvents } = useEventsInfiniteQuery(); + const relevantEvents = removeBlocklistedEvents(fetchedEvents); const { isTaxIdEnabled } = useIsTaxIdEnabled(); const notificationContext = React.useContext(_notificationContext); // TODO: TaxId - This entire function can be removed when we cleanup tax id feature flags - const unwantedEvents = React.useMemo(() => { - const events = [...defaultUnwantedEvents]; + const unwantedEventTypes = React.useMemo(() => { + const eventTypes = [...defaultUnwantedEvents]; if (!isTaxIdEnabled) { - events.push('tax_id_invalid'); + eventTypes.push('tax_id_invalid'); } - return events; + return eventTypes; }, [isTaxIdEnabled]); - const _events = events.filter( - (thisEvent) => !unwantedEvents.includes(thisEvent.action) + const filteredEvents = relevantEvents.filter( + (event) => !unwantedEventTypes.includes(event.action) ); - const [inProgress, completed] = partition(isInProgressEvent, _events); + const [inProgressEvents, completedEvents] = partition( + isInProgressEvent, + filteredEvents + ); - const allEvents = [ - ...inProgress.map((thisEvent) => - formatProgressEventForDisplay(thisEvent, notificationContext.closeMenu) + const allNotificationItems = [ + ...inProgressEvents.map((event) => + formatProgressEventForDisplay(event, notificationContext.closeMenu) ), - ...completed.map((thisEvent) => - formatEventForDisplay(thisEvent, notificationContext.closeMenu) + ...completedEvents.map((event) => + formatEventForDisplay(event, notificationContext.closeMenu) ), ]; - return allEvents.filter((thisAction) => - Boolean(thisAction.body) - ) as NotificationItem[]; + return allNotificationItems.filter((notification) => + Boolean(notification.body) + ); }; const formatEventForDisplay = ( From 2e0d0cbd3c26bcbcc358db8fb14b2ecb9f337596 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Mon, 24 Jun 2024 11:56:52 -0500 Subject: [PATCH 115/163] Update the changelog --- packages/api-v4/CHANGELOG.md | 10 +--------- .../pr-10545-upcoming-features-1718402597165.md | 5 ----- .../manager/.changeset/pr-10597-fixed-1718893685492.md | 5 ----- .../manager/.changeset/pr-10602-tests-1718985699307.md | 5 ----- packages/manager/CHANGELOG.md | 4 +++- 5 files changed, 4 insertions(+), 25 deletions(-) delete mode 100644 packages/manager/.changeset/pr-10545-upcoming-features-1718402597165.md delete mode 100644 packages/manager/.changeset/pr-10597-fixed-1718893685492.md delete mode 100644 packages/manager/.changeset/pr-10602-tests-1718985699307.md diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 487a58ca1bc..75ab194a0c6 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,15 +1,13 @@ ## [2024-06-24] - v0.120.0 - ### Added: - New endpoint for LKE HA types used in pricing ([#10505](https://github.com/linode/manager/pull/10505)) - UpdateImagePayload type ([#10514](https://github.com/linode/manager/pull/10514)) -- New endpoint for `network-transfer/prices`` ([#10566](https://github.com/linode/manager/pull/10566)) +- New endpoint for `network-transfer/prices` ([#10566](https://github.com/linode/manager/pull/10566)) ## [2024-06-10] - v0.119.0 - ### Added: - `tags` field in `Image` type ([#10466](https://github.com/linode/manager/pull/10466)) @@ -28,7 +26,6 @@ ## [2024-05-28] - v0.118.0 - ### Added: - New LKE events in `EventAction` type ([#10443](https://github.com/linode/manager/pull/10443)) @@ -37,10 +34,8 @@ - Add Disk Encryption to AccountCapability type and region Capabilities type ([#10462](https://github.com/linode/manager/pull/10462)) - ## [2024-05-13] - v0.117.0 - ### Added: - 'edge' Linode type class ([#10415](https://github.com/linode/manager/pull/10415)) @@ -55,17 +50,14 @@ - Update Placement Group event types ([#10420](https://github.com/linode/manager/pull/10420)) - ## [2024-05-06] - v0.116.0 - ### Added: - 'edge' Linode type class ([#10441](https://github.com/linode/manager/pull/10441)) ## [2024-04-29] - v0.115.0 - ### Added: - New endpoint for `volumes/types` ([#10376](https://github.com/linode/manager/pull/10376)) diff --git a/packages/manager/.changeset/pr-10545-upcoming-features-1718402597165.md b/packages/manager/.changeset/pr-10545-upcoming-features-1718402597165.md deleted file mode 100644 index 3651a31e1e8..00000000000 --- a/packages/manager/.changeset/pr-10545-upcoming-features-1718402597165.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Update Images Landing table ([#10545](https://github.com/linode/manager/pull/10545)) diff --git a/packages/manager/.changeset/pr-10597-fixed-1718893685492.md b/packages/manager/.changeset/pr-10597-fixed-1718893685492.md deleted file mode 100644 index bce06d11bc7..00000000000 --- a/packages/manager/.changeset/pr-10597-fixed-1718893685492.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -fix: [M3-8274] - Fix parsing issue causing in Kubernetes Version field ([#10597](https://github.com/linode/manager/pull/10597)) diff --git a/packages/manager/.changeset/pr-10602-tests-1718985699307.md b/packages/manager/.changeset/pr-10602-tests-1718985699307.md deleted file mode 100644 index 82ccb0de864..00000000000 --- a/packages/manager/.changeset/pr-10602-tests-1718985699307.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Update Object Storage tests to mock account capabilities as needed for multi cluster ([#10602](https://github.com/linode/manager/pull/10602)) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 3d97006b2ef..220f3ccf276 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - CONTRIBUTING doc page commit type list markup ([#10587](https://github.com/linode/manager/pull/10587)) - React Query Events `seen` behavior and other optimizations ([#10588](https://github.com/linode/manager/pull/10588)) - Accessibility: Add tabindex to TextTooltip ([#10590](https://github.com/linode/manager/pull/10590)) +- Fix parsing issue causing in Kubernetes Version field ([#10597](https://github.com/linode/manager/pull/10597)) ### Tech Stories: @@ -60,7 +61,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Revise LDE copy in Linode Create flow when Distributed region is selected ([#10576](https://github.com/linode/manager/pull/10576)) - Update description for Add Node Pools section in LKE Create flow ([#10578](https://github.com/linode/manager/pull/10578)) - Linode Create v2 - Add Marketplace Searching / Filtering ([#10586](https://github.com/linode/manager/pull/10586)) -- Add Distributed Icon to ImageSelects for distributed compatible images ([#10592](https://github.com/linode/manager/pull/10592)) +- Add Distributed Icon to ImageSelects for distributed compatible images ([#10592](https://github.com/linode/manager/pull/10592) +- Update Images Landing table ([#10545](https://github.com/linode/manager/pull/10545)) ## [2024-06-21] - v1.121.2 From 2d6f1d0adced7be2ba026126566e7c56c948fe4e Mon Sep 17 00:00:00 2001 From: Azure-akamai Date: Mon, 24 Jun 2024 13:19:52 -0400 Subject: [PATCH 116/163] test: [M3-8107] - Refactor Cypress Longview test to use mock API data/events (#10579) * update tests for check longview install with mock data * Update tests * Added changeset: Refactor Cypress Longview test to use mock API data/events * update according to the review * update after reviews --- .../pr-10579-tests-1718297804893.md | 5 + .../e2e/core/longview/longview.spec.ts | 256 +++++++++++------- .../cypress/support/intercepts/longview.ts | 36 ++- .../manager/src/factories/longviewDisks.ts | 125 ++++++++- .../manager/src/factories/longviewResponse.ts | 50 +++- 5 files changed, 353 insertions(+), 119 deletions(-) create mode 100644 packages/manager/.changeset/pr-10579-tests-1718297804893.md diff --git a/packages/manager/.changeset/pr-10579-tests-1718297804893.md b/packages/manager/.changeset/pr-10579-tests-1718297804893.md new file mode 100644 index 00000000000..ecd89ebaf74 --- /dev/null +++ b/packages/manager/.changeset/pr-10579-tests-1718297804893.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Refactor Cypress Longview test to use mock API data/events ([#10579](https://github.com/linode/manager/pull/10579)) diff --git a/packages/manager/cypress/e2e/core/longview/longview.spec.ts b/packages/manager/cypress/e2e/core/longview/longview.spec.ts index 4ecfed6ca39..320f57da41e 100644 --- a/packages/manager/cypress/e2e/core/longview/longview.spec.ts +++ b/packages/manager/cypress/e2e/core/longview/longview.spec.ts @@ -1,28 +1,26 @@ -import type { Linode, LongviewClient } from '@linode/api-v4'; -import { createLongviewClient } from '@linode/api-v4'; -import { longviewResponseFactory, longviewClientFactory } from 'src/factories'; -import { LongviewResponse } from 'src/features/Longview/request.types'; +import type { LongviewClient } from '@linode/api-v4'; +import { DateTime } from 'luxon'; +import { + longviewResponseFactory, + longviewClientFactory, + longviewAppsFactory, + longviewLatestStatsFactory, + longviewPackageFactory, +} from 'src/factories'; import { authenticate } from 'support/api/authentication'; import { - longviewInstallTimeout, longviewStatusTimeout, longviewEmptyStateMessage, longviewAddClientButtonText, } from 'support/constants/longview'; import { interceptFetchLongviewStatus, - interceptGetLongviewClients, mockGetLongviewClients, mockFetchLongviewStatus, mockCreateLongviewClient, } from 'support/intercepts/longview'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; -import { createTestLinode } from 'support/util/linodes'; -import { randomLabel, randomString } from 'support/util/random'; - -// Timeout if Linode creation and boot takes longer than 1 and a half minutes. -const linodeCreateTimeout = 90000; /** * Returns the command used to install Longview which is shown in Cloud's UI. @@ -35,31 +33,6 @@ const getInstallCommand = (installCode: string): string => { return `curl -s https://lv.linode.com/${installCode} | sudo bash`; }; -/** - * Installs Longview on a Linode. - * - * @param linodeIp - IP of Linode on which to install Longview. - * @param linodePass - Root password of Linode on which to install Longview. - * @param installCommand - Longview installation command. - * - * @returns Cypress chainable. - */ -const installLongview = ( - linodeIp: string, - linodePass: string, - installCommand: string -) => { - return cy.exec('./cypress/support/scripts/longview/install-longview.sh', { - failOnNonZeroExit: true, - timeout: longviewInstallTimeout, - env: { - LINODEIP: linodeIp, - LINODEPASSWORD: linodePass, - CURLCOMMAND: installCommand, - }, - }); -}; - /** * Waits for Cloud Manager to fetch Longview data and receive updates. * @@ -100,6 +73,58 @@ const waitForLongviewData = ( ); }; +/* + * Mocks that represent the state of Longview while waiting for client to be installed. + */ +const longviewLastUpdatedWaiting = longviewResponseFactory.build({ + ACTION: 'lastUpdated', + DATA: { updated: 0 }, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +const longviewGetValuesWaiting = longviewResponseFactory.build({ + ACTION: 'getValues', + DATA: {}, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +const longviewGetLatestValueWaiting = longviewResponseFactory.build({ + ACTION: 'getLatestValue', + DATA: {}, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +/* + * Mocks that represent the state of Longview once client is installed and data is received. + */ +const longviewLastUpdatedInstalled = longviewResponseFactory.build({ + ACTION: 'lastUpdated', + DATA: { + updated: DateTime.now().plus({ minutes: 1 }).toSeconds(), + }, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +const longviewGetValuesInstalled = longviewResponseFactory.build({ + ACTION: 'getValues', + DATA: { + Packages: longviewPackageFactory.buildList(5), + }, + NOTIFICATIONS: [], + VERSION: 0.4, +}); + +const longviewGetLatestValueInstalled = longviewResponseFactory.build({ + ACTION: 'getLatestValue', + DATA: longviewLatestStatsFactory.build(), + NOTIFICATIONS: [], + VERSION: 0.4, +}); + authenticate(); describe('longview', () => { before(() => { @@ -107,78 +132,76 @@ describe('longview', () => { }); /* - * - Tests Longview installation end-to-end using real API data. - * - Creates a Linode, connects to it via SSH, and installs Longview using the given cURL command. + * - Tests Longview installation end-to-end using mock API data. * - Confirms that Cloud Manager UI updates to reflect Longview installation and data. */ - // TODO Unskip for M3-8107. - it.skip('can install Longview client on a Linode', () => { - const linodePassword = randomString(32, { - symbols: false, - lowercase: true, - uppercase: true, - numbers: true, - spaces: false, + + it('can install Longview client on a Linode', () => { + const client: LongviewClient = longviewClientFactory.build({ + api_key: '01AE82DD-6F99-44F6-95781512B64FFBC3', + apps: longviewAppsFactory.build(), + created: new Date().toISOString(), + id: 338283, + install_code: '748632FC-E92B-491F-A29D44019039017C', + label: 'longview-client-longview338283', + updated: new Date().toISOString(), }); - const createLinodeAndClient = async () => { - return Promise.all([ - createTestLinode({ - root_pass: linodePassword, - type: 'g6-standard-1', - booted: true, - }), - createLongviewClient(randomLabel()), - ]); - }; - - // Create Linode and Longview Client before loading Longview landing page. - cy.defer(createLinodeAndClient, { - label: 'Creating Linode and Longview Client...', - timeout: linodeCreateTimeout, - }).then(([linode, client]: [Linode, LongviewClient]) => { - const linodeIp = linode.ipv4[0]; - const installCommand = getInstallCommand(client.install_code); - - interceptGetLongviewClients().as('getLongviewClients'); - interceptFetchLongviewStatus().as('fetchLongviewStatus'); - cy.visitWithLogin('/longview'); - cy.wait('@getLongviewClients'); - - // Find the table row for the new Longview client, assert expected information - // is displayed inside of it. - cy.get(`[data-qa-longview-client="${client.id}"]`) - .should('be.visible') - .within(() => { - cy.findByText(client.label).should('be.visible'); - cy.findByText(client.api_key).should('be.visible'); - cy.contains(installCommand).should('be.visible'); - cy.findByText('Waiting for data...'); - }); - - // Install Longview on Linode by SSHing into machine and executing cURL command. - installLongview(linodeIp, linodePassword, installCommand); - - // Wait for Longview to begin serving data and confirm that Cloud Manager - // UI updates accordingly. - waitForLongviewData('fetchLongviewStatus', client.api_key); - - // Sometimes Cloud Manager UI does not updated automatically upon receiving - // Longivew status data. Performing a page reload mitigates this issue. - // TODO Remove call to `cy.reload()`. - cy.reload(); - cy.get(`[data-qa-longview-client="${client.id}"]`) - .should('be.visible') - .within(() => { - cy.findByText('Waiting for data...').should('not.exist'); - cy.findByText('CPU').should('be.visible'); - cy.findByText('RAM').should('be.visible'); - cy.findByText('Swap').should('be.visible'); - cy.findByText('Load').should('be.visible'); - cy.findByText('Network').should('be.visible'); - cy.findByText('Storage').should('be.visible'); - }); + mockGetLongviewClients([client]).as('getLongviewClients'); + mockFetchLongviewStatus(client, 'lastUpdated', longviewLastUpdatedWaiting); + mockFetchLongviewStatus(client, 'getValues', longviewGetValuesWaiting); + mockFetchLongviewStatus( + client, + 'getLatestValue', + longviewGetLatestValueWaiting + ).as('fetchLongview'); + + const installCommand = getInstallCommand(client.install_code); + + cy.visitWithLogin('/longview'); + cy.wait('@getLongviewClients'); + + // Confirm that Longview landing page lists a client that is still waiting for data... + cy.get(`[data-qa-longview-client="${client.id}"]`) + .should('be.visible') + .within(() => { + cy.findByText(client.label).should('be.visible'); + cy.findByText(client.api_key).should('be.visible'); + cy.contains(installCommand).should('be.visible'); + cy.findByText('Waiting for data...'); + }); + + // Update mocks after initial Longview fetch to simulate client installation and data retrieval. + // The next time Cloud makes a request to the fetch endpoint, data will start being returned. + // 3 fetches is necessary because the Longview landing page fires 3 requests to the Longview fetch endpoint for each client. + // See https://github.com/linode/manager/pull/10579#discussion_r1647945160 + cy.wait(['@fetchLongview', '@fetchLongview', '@fetchLongview']).then(() => { + mockFetchLongviewStatus( + client, + 'lastUpdated', + longviewLastUpdatedInstalled + ); + mockFetchLongviewStatus(client, 'getValues', longviewGetValuesInstalled); + mockFetchLongviewStatus( + client, + 'getLatestValue', + longviewGetLatestValueInstalled + ); }); + + // Confirms that UI updates to show that data has been retrieved. + cy.findByText(`${client.label}`).should('be.visible'); + cy.get(`[data-qa-longview-client="${client.id}"]`) + .should('be.visible') + .within(() => { + cy.findByText('Waiting for data...').should('not.exist'); + cy.findByText('CPU').should('be.visible'); + cy.findByText('RAM').should('be.visible'); + cy.findByText('Swap').should('be.visible'); + cy.findByText('Load').should('be.visible'); + cy.findByText('Network').should('be.visible'); + cy.findByText('Storage').should('be.visible'); + }); }); /* @@ -187,10 +210,15 @@ describe('longview', () => { */ it('displays empty state message when no clients are present and shows the new client when creating one', () => { const client: LongviewClient = longviewClientFactory.build(); - const status: LongviewResponse = longviewResponseFactory.build(); mockGetLongviewClients([]).as('getLongviewClients'); mockCreateLongviewClient(client).as('createLongviewClient'); - mockFetchLongviewStatus(status).as('fetchLongviewStatus'); + mockFetchLongviewStatus(client, 'lastUpdated', longviewLastUpdatedWaiting); + mockFetchLongviewStatus(client, 'getValues', longviewGetValuesWaiting); + mockFetchLongviewStatus( + client, + 'getLatestValue', + longviewGetLatestValueWaiting + ).as('fetchLongview'); cy.visitWithLogin('/longview'); cy.wait('@getLongviewClients'); @@ -206,6 +234,24 @@ describe('longview', () => { .click(); cy.wait('@createLongviewClient'); + // Update mocks after initial Longview fetch to simulate client installation and data retrieval. + // The next time Cloud makes a request to the fetch endpoint, data will start being returned. + // 3 fetches is necessary because the Longview landing page fires 3 requests to the Longview fetch endpoint for each client. + // See https://github.com/linode/manager/pull/10579#discussion_r1647945160 + cy.wait(['@fetchLongview', '@fetchLongview', '@fetchLongview']).then(() => { + mockFetchLongviewStatus( + client, + 'lastUpdated', + longviewLastUpdatedInstalled + ); + mockFetchLongviewStatus(client, 'getValues', longviewGetValuesInstalled); + mockFetchLongviewStatus( + client, + 'getLatestValue', + longviewGetLatestValueInstalled + ); + }); + // Confirms that UI updates to show the new client when creating one. cy.findByText(`${client.label}`).should('be.visible'); cy.get(`[data-qa-longview-client="${client.id}"]`) diff --git a/packages/manager/cypress/support/intercepts/longview.ts b/packages/manager/cypress/support/intercepts/longview.ts index d0cb2c742fc..2bc3eaf27a9 100644 --- a/packages/manager/cypress/support/intercepts/longview.ts +++ b/packages/manager/cypress/support/intercepts/longview.ts @@ -2,7 +2,10 @@ import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; import { LongviewClient } from '@linode/api-v4'; -import { LongviewResponse } from 'src/features/Longview/request.types'; +import type { + LongviewAction, + LongviewResponse, +} from 'src/features/Longview/request.types'; /** * Intercepts request to retrieve Longview status for a Longview client. @@ -16,15 +19,38 @@ export const interceptFetchLongviewStatus = (): Cypress.Chainable => { /** * Mocks request to retrieve Longview status for a Longview client. * + * @param client - Longview Client for which to intercept Longview fetch request. + * @param apiAction - Longview API action to intercept. + * @param mockStatus - + * * @returns Cypress chainable. */ export const mockFetchLongviewStatus = ( - status: LongviewResponse + client: LongviewClient, + apiAction: LongviewAction, + mockStatus: LongviewResponse ): Cypress.Chainable => { return cy.intercept( - 'POST', - 'https://longview.linode.com/fetch', - makeResponse(status) + { + url: 'https://longview.linode.com/fetch', + method: 'POST', + }, + async (req) => { + const payload = req.body; + const response = new Response(payload, { + headers: { + 'content-type': req.headers['content-type'] as string, + }, + }); + const formData = await response.formData(); + + if ( + formData.get('api_key') === client.api_key && + formData.get('api_action') === apiAction + ) { + req.reply(makeResponse([mockStatus])); + } + } ); }; diff --git a/packages/manager/src/factories/longviewDisks.ts b/packages/manager/src/factories/longviewDisks.ts index 35d77269cb6..5a5dc8ca1fc 100644 --- a/packages/manager/src/factories/longviewDisks.ts +++ b/packages/manager/src/factories/longviewDisks.ts @@ -1,11 +1,37 @@ import * as Factory from 'factory.ts'; -import { Disk, LongviewDisk } from 'src/features/Longview/request.types'; +import { + Disk, + LongviewDisk, + LongviewCPU, + CPU, + LongviewSystemInfo, + LongviewNetworkInterface, + InboundOutboundNetwork, + LongviewNetwork, + LongviewMemory, + LongviewLoad, + Uptime, +} from 'src/features/Longview/request.types'; const mockStats = [ - { x: 0, y: 1 }, - { x: 0, y: 2 }, - { x: 0, y: 3 }, + { x: 1717770900, y: 0 }, + { x: 1717770900, y: 20877.4637037037 }, + { x: 1717770900, y: 4.09420479302832 }, + { x: 1717770900, y: 83937959936 }, + { x: 1717770900, y: 5173267 }, + { x: 1717770900, y: 5210112 }, + { x: 1717770900, y: 82699642934.6133 }, + { x: 1717770900, y: 0.0372984749455338 }, + { x: 1717770900, y: 0.00723311546840959 }, + { x: 1717770900, y: 0.0918300653594771 }, + { x: 1717770900, y: 466.120718954248 }, + { x: 1717770900, y: 451.9651416122 }, + { x: 1717770900, y: 524284 }, + { x: 1717770900, y: 547242.706666667 }, + { x: 1717770900, y: 3466265.29333333 }, + { x: 1717770900, y: 57237.6133333333 }, + { x: 1717770900, y: 365385.893333333 }, ]; export const diskFactory = Factory.Sync.makeFactory({ @@ -14,8 +40,23 @@ export const diskFactory = Factory.Sync.makeFactory({ dm: 0, isswap: 0, mounted: 1, - reads: mockStats, - writes: mockStats, + reads: [mockStats[0]], + write_bytes: [mockStats[1]], + writes: [mockStats[2]], + fs: { + total: [mockStats[3]], + ifree: [mockStats[4]], + itotal: [mockStats[5]], + path: '/', + free: [mockStats[6]], + }, + read_bytes: [mockStats[0]], +}); + +export const cpuFactory = Factory.Sync.makeFactory({ + system: [mockStats[7]], + wait: [mockStats[8]], + user: [mockStats[9]], }); export const longviewDiskFactory = Factory.Sync.makeFactory({ @@ -24,3 +65,75 @@ export const longviewDiskFactory = Factory.Sync.makeFactory({ '/dev/sdb': diskFactory.build({ isswap: 1 }), }, }); + +export const longviewCPUFactory = Factory.Sync.makeFactory({ + CPU: { + cpu0: cpuFactory.build(), + cpu1: cpuFactory.build(), + }, +}); + +export const longviewSysInfoFactory = Factory.Sync.makeFactory( + { + SysInfo: { + arch: 'x86_64', + client: '1.1.5', + cpu: { + cores: 2, + type: 'AMD EPYC 7713 64-Core Processor', + }, + hostname: 'localhost', + kernel: 'Linux 5.10.0-28-amd64', + os: { + dist: 'Debian', + distversion: '11.9', + }, + type: 'kvm', + }, + } +); + +export const InboundOutboundNetworkFactory = Factory.Sync.makeFactory( + { + rx_bytes: [mockStats[10]], + tx_bytes: [mockStats[11]], + } +); + +export const LongviewNetworkInterfaceFactory = Factory.Sync.makeFactory( + { + eth0: InboundOutboundNetworkFactory.build(), + } +); + +export const longviewNetworkFactory = Factory.Sync.makeFactory( + { + Network: { + Interface: LongviewNetworkInterfaceFactory.build(), + mac_addr: 'f2:3c:94:e6:81:e2', + }, + } +); + +export const LongviewMemoryFactory = Factory.Sync.makeFactory({ + Memory: { + swap: { + free: [mockStats[12]], + used: [mockStats[0]], + }, + real: { + used: [mockStats[13]], + free: [mockStats[14]], + buffers: [mockStats[15]], + cache: [mockStats[16]], + }, + }, +}); + +export const LongviewLoadFactory = Factory.Sync.makeFactory({ + Load: [mockStats[0]], +}); + +export const UptimeFactory = Factory.Sync.makeFactory({ + uptime: 84516.53, +}); diff --git a/packages/manager/src/factories/longviewResponse.ts b/packages/manager/src/factories/longviewResponse.ts index 315fad71bff..fa992343ae9 100644 --- a/packages/manager/src/factories/longviewResponse.ts +++ b/packages/manager/src/factories/longviewResponse.ts @@ -1,14 +1,58 @@ import * as Factory from 'factory.ts'; import { LongviewResponse } from 'src/features/Longview/request.types'; +import { AllData, LongviewPackage } from 'src/features/Longview/request.types'; -import { longviewDiskFactory } from './longviewDisks'; +import { + longviewDiskFactory, + longviewCPUFactory, + longviewSysInfoFactory, + longviewNetworkFactory, + LongviewMemoryFactory, + LongviewLoadFactory, + UptimeFactory, +} from './longviewDisks'; + +const longviewResponseData = () => { + const diskData = longviewDiskFactory.build(); + const cpuData = longviewCPUFactory.build(); + const sysinfoData = longviewSysInfoFactory.build(); + const networkData = longviewNetworkFactory.build(); + const memoryData = LongviewMemoryFactory.build(); + const loadData = LongviewLoadFactory.build(); + const uptimeData = UptimeFactory.build(); + + return { + ...diskData, + ...cpuData, + ...sysinfoData, + ...networkData, + ...memoryData, + ...loadData, + ...uptimeData, + }; +}; export const longviewResponseFactory = Factory.Sync.makeFactory( { - ACTION: 'getValues', - DATA: longviewDiskFactory.build(), + ACTION: 'getLatestValue', + DATA: {}, NOTIFICATIONS: [], VERSION: 0.4, } ); + +export const longviewLatestStatsFactory = Factory.Sync.makeFactory< + Partial +>({ + ...longviewResponseData(), +}); + +export const longviewPackageFactory = Factory.Sync.makeFactory( + { + current: Factory.each((i) => `${i + 1}.${i + 2}.${i + 3}`), + held: 0, + name: Factory.each((i) => `mock-package-${i}`), + new: Factory.each((i) => `${i + 1}.${i + 2}.${i + 3}`), + } +); From fc680a6a41cdab74f5f0839381bacc596d8c805c Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Mon, 24 Jun 2024 13:31:22 -0400 Subject: [PATCH 117/163] feat: [M3-7579] - Design Tokens (CDS 2.0) (#10022) * Add design tokens * Updates to status colors * Saving... * Saving... * Version 14 * Update version to v1.0.0 * Added changeset: Added Design Tokens (CDS 2.0) * Update to latest version * Revert * Bump version * Updates to chip palette and update endpoint palette to use darker error color * Remove unnecessary background color for notice story * Update textfields, borders and outlines * Fix dark mode button * Revert input token changes * Revert border tokens and fix marketplace background * Fix darkmode * Dark mode tokens * More dark mode fixes * Update MuiFormHelperText dark mode color * Yarn lock * fix units * Fix disabled buttons * Saving * Saving... * Adjust billing paper * Work in progress * More style updates * Minor * Add feature flag * More updates * Modal close * Nearing the end * update snapshots * Snaclbar and placeholder update * Tefield updates dark theme * Update to menu items * react select placeholder * feedback @dwiley-akamai * feedback * Revert nav hover colors back to Linode green * last feedback bit --------- Co-authored-by: Jaalah Ramos Co-authored-by: Alban Bailly --- .../pr-10022-added-1703703204947.md | 5 + packages/manager/package.json | 1 + .../src/components/ActionMenu/ActionMenu.tsx | 31 +- .../src/components/BetaChip/BetaChip.test.tsx | 2 +- .../components/Breadcrumb/Crumbs.styles.tsx | 3 +- .../components/Button/StyledActionButton.ts | 6 +- .../src/components/Button/StyledLinkButton.ts | 17 +- .../src/components/Button/StyledTagButton.ts | 7 +- .../CollapsibleTable/CollapsibleTable.tsx | 40 +- .../ColorPalette/ColorPalette.test.tsx | 132 ----- .../components/ColorPalette/ColorPalette.tsx | 8 +- packages/manager/src/components/Divider.tsx | 7 - .../src/components/DocsLink/DocsLink.tsx | 5 +- .../EnhancedSelect/Select.styles.ts | 6 +- .../src/components/EnhancedSelect/Select.tsx | 24 +- .../EntityHeader/EntityHeader.stories.tsx | 24 +- .../HighlightedMarkdown.test.tsx.snap | 2 +- .../InlineMenuAction/InlineMenuAction.tsx | 2 +- .../src/components/Notice/Notice.stories.tsx | 2 - .../src/components/Notice/Notice.test.tsx | 2 +- .../PrimaryNav/PrimaryNav.styles.ts | 19 +- .../src/components/PrimaryNav/PrimaryNav.tsx | 100 ++-- .../src/components/PrimaryNav/SideMenu.tsx | 3 +- .../SelectionCard/CardBase.styles.ts | 20 +- .../src/components/Snackbar/Snackbar.tsx | 11 +- .../src/components/StatusIcon/StatusIcon.tsx | 6 +- .../src/components/Table/Table.styles.ts | 13 +- .../components/TableRow/TableRow.styles.ts | 11 +- .../TableSortCell/TableSortCell.tsx | 1 - .../manager/src/components/Tabs/Tab.test.tsx | 2 +- packages/manager/src/components/Tabs/Tab.tsx | 4 +- .../manager/src/components/Tabs/TabList.tsx | 4 +- .../Tabs/__snapshots__/TabList.test.tsx.snap | 2 +- .../manager/src/components/Tag/Tag.styles.ts | 22 +- .../src/components/TagCell/TagCell.tsx | 2 +- .../TextTooltip/TextTooltip.test.tsx | 2 +- .../components/TextTooltip/TextTooltip.tsx | 7 +- .../src/components/Tile/Tile.styles.ts | 6 +- .../manager/src/components/TooltipIcon.tsx | 12 +- .../BillingActivityPanel.tsx | 17 +- .../Billing/InvoiceDetail/InvoiceTable.tsx | 17 +- .../src/features/Help/Panels/PopularPosts.tsx | 4 +- .../src/features/Help/Panels/SearchPanel.tsx | 7 +- .../Linodes/LinodeEntityDetail.styles.ts | 4 +- .../Linodes/LinodeEntityDetailHeader.tsx | 21 +- .../Linodes/LinodesCreate/SelectAppPanel.tsx | 1 + .../TabbedContent/FromAppsContent.tsx | 2 +- .../EndpointHealth.test.tsx | 11 +- .../ServiceTargets/EndpointTable.tsx | 8 +- .../StackScriptForm/StackScriptForm.styles.ts | 2 +- .../TopMenu/AddNewMenu/AddNewMenu.tsx | 23 +- .../TopMenu/SearchBar/SearchBar.styles.ts | 24 +- .../manager/src/features/TopMenu/TopMenu.tsx | 9 +- .../src/features/TopMenu/TopMenuTooltip.tsx | 1 + .../VPCs/VPCDetail/VPCDetail.styles.ts | 6 +- .../src/features/VPCs/VPCDetail/VPCDetail.tsx | 6 +- .../PlansPanel/PlanContainer.styles.ts | 2 - .../manager/src/foundations/themes/dark.ts | 510 ++++++++++++------ .../manager/src/foundations/themes/index.ts | 2 + .../manager/src/foundations/themes/light.ts | 420 +++++++++------ packages/manager/src/index.css | 4 - yarn.lock | 402 +++++++++++++- 62 files changed, 1287 insertions(+), 789 deletions(-) create mode 100644 packages/manager/.changeset/pr-10022-added-1703703204947.md delete mode 100644 packages/manager/src/components/ColorPalette/ColorPalette.test.tsx diff --git a/packages/manager/.changeset/pr-10022-added-1703703204947.md b/packages/manager/.changeset/pr-10022-added-1703703204947.md new file mode 100644 index 00000000000..1a6769e2894 --- /dev/null +++ b/packages/manager/.changeset/pr-10022-added-1703703204947.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Added Design Tokens (CDS 2.0) ([#10022](https://github.com/linode/manager/pull/10022)) diff --git a/packages/manager/package.json b/packages/manager/package.json index 7b0f2f562a6..5c1fdfb36b0 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -18,6 +18,7 @@ "@emotion/styled": "^11.11.0", "@hookform/resolvers": "2.9.11", "@linode/api-v4": "*", + "@linode/design-language-system": "^2.3.0", "@linode/validation": "*", "@lukemorales/query-key-factory": "^1.3.4", "@mui/icons-material": "^5.14.7", diff --git a/packages/manager/src/components/ActionMenu/ActionMenu.tsx b/packages/manager/src/components/ActionMenu/ActionMenu.tsx index 679c0b34df9..6f5faf359b1 100644 --- a/packages/manager/src/components/ActionMenu/ActionMenu.tsx +++ b/packages/manager/src/components/ActionMenu/ActionMenu.tsx @@ -1,4 +1,4 @@ -import { IconButton, ListItemText, useTheme } from '@mui/material'; +import { IconButton, ListItemText } from '@mui/material'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import * as React from 'react'; @@ -37,7 +37,6 @@ export interface ActionMenuProps { */ export const ActionMenu = React.memo((props: ActionMenuProps) => { const { actionsList, ariaLabel, onOpen } = props; - const theme = useTheme(); const menuId = convertToKebabCase(ariaLabel); const buttonId = `${convertToKebabCase(ariaLabel)}-button`; @@ -70,16 +69,6 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { } const sxTooltipIcon = { - '& :hover': { - color: '#4d99f1', - }, - '&& .MuiSvgIcon-root': { - fill: theme.color.disabledText, - height: '20px', - width: '20px', - }, - - color: '#fff', padding: '0 0 0 8px', pointerEvents: 'all', // Allows the tooltip to be hovered on a disabled MenuItem }; @@ -89,12 +78,12 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { ({ ':hover': { - backgroundColor: theme.palette.primary.main, - color: '#fff', + backgroundColor: theme.color.buttonPrimaryHover, + color: theme.color.white, }, - backgroundColor: open ? theme.palette.primary.main : undefined, + backgroundColor: open ? theme.color.buttonPrimaryHover : undefined, borderRadius: 'unset', - color: open ? '#fff' : theme.textColors.linkActiveLight, + color: open ? theme.color.white : theme.textColors.linkActiveLight, height: '100%', minWidth: '40px', padding: '10px', @@ -122,7 +111,6 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { paper: { sx: (theme) => ({ backgroundColor: theme.palette.primary.main, - boxShadow: 'none', }), }, }} @@ -147,15 +135,6 @@ export const ActionMenu = React.memo((props: ActionMenuProps) => { a.onClick(); } }} - sx={{ - '&:hover': { - background: '#226dc3', - }, - background: '#3683dc', - borderBottom: '1px solid #5294e0', - color: '#fff', - padding: '10px 10px 10px 16px', - }} data-qa-action-menu-item={a.title} data-testid={a.title} disabled={a.disabled} diff --git a/packages/manager/src/components/BetaChip/BetaChip.test.tsx b/packages/manager/src/components/BetaChip/BetaChip.test.tsx index 47a86d03207..39d28178640 100644 --- a/packages/manager/src/components/BetaChip/BetaChip.test.tsx +++ b/packages/manager/src/components/BetaChip/BetaChip.test.tsx @@ -17,7 +17,7 @@ describe('BetaChip', () => { const { getByTestId } = renderWithTheme(); const betaChip = getByTestId('betaChip'); expect(betaChip).toBeInTheDocument(); - expect(betaChip).toHaveStyle('background-color: #3683dc'); + expect(betaChip).toHaveStyle('background-color: rgb(16, 138, 214)'); }); it('triggers an onClick callback', () => { diff --git a/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx b/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx index 8fe3480a3a9..848a0a164bc 100644 --- a/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx +++ b/packages/manager/src/components/Breadcrumb/Crumbs.styles.tsx @@ -4,11 +4,10 @@ import { Typography } from 'src/components/Typography'; export const StyledTypography = styled(Typography, { label: 'StyledTypography', -})(({ theme }) => ({ +})(({}) => ({ '&:hover': { textDecoration: 'underline', }, - color: theme.textColors.tableHeader, fontSize: '1.125rem', lineHeight: 'normal', textTransform: 'capitalize', diff --git a/packages/manager/src/components/Button/StyledActionButton.ts b/packages/manager/src/components/Button/StyledActionButton.ts index aa711d36626..008257f5a50 100644 --- a/packages/manager/src/components/Button/StyledActionButton.ts +++ b/packages/manager/src/components/Button/StyledActionButton.ts @@ -15,10 +15,12 @@ export const StyledActionButton = styled(Button, { })(({ theme, ...props }) => ({ ...(!props.disabled && { '&:hover': { - backgroundColor: theme.palette.primary.main, - color: theme.name === 'dark' ? theme.color.black : theme.color.white, + backgroundColor: theme.color.buttonPrimaryHover, + color: theme.color.white, }, }), + background: 'transparent', + color: theme.textColors.linkActiveLight, fontFamily: latoWeb.normal, fontSize: '14px', lineHeight: '16px', diff --git a/packages/manager/src/components/Button/StyledLinkButton.ts b/packages/manager/src/components/Button/StyledLinkButton.ts index 8c4cec0b4a8..1688a156f79 100644 --- a/packages/manager/src/components/Button/StyledLinkButton.ts +++ b/packages/manager/src/components/Button/StyledLinkButton.ts @@ -10,20 +10,5 @@ import { styled } from '@mui/material/styles'; export const StyledLinkButton = styled('button', { label: 'StyledLinkButton', })(({ theme }) => ({ - '&:disabled': { - color: theme.palette.text.disabled, - cursor: 'not-allowed', - }, - '&:hover:not(:disabled)': { - backgroundColor: 'transparent', - color: theme.palette.primary.main, - textDecoration: 'underline', - }, - background: 'none', - border: 'none', - color: theme.textColors.linkActiveLight, - cursor: 'pointer', - font: 'inherit', - minWidth: 0, - padding: 0, + ...theme.applyLinkStyles, })); diff --git a/packages/manager/src/components/Button/StyledTagButton.ts b/packages/manager/src/components/Button/StyledTagButton.ts index df83fcc4c88..d0dae58b7cd 100644 --- a/packages/manager/src/components/Button/StyledTagButton.ts +++ b/packages/manager/src/components/Button/StyledTagButton.ts @@ -24,11 +24,12 @@ export const StyledTagButton = styled(Button, { }), ...(!props.disabled && { '&:hover, &:focus': { - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, border: 'none', + color: theme.color.tagButtonText, }, - backgroundColor: theme.color.tagButton, - color: theme.textColors.linkActiveLight, + backgroundColor: theme.color.tagButtonBg, + color: theme.color.tagButtonText, }), })); diff --git a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx index 0fe1a57cbae..bd844227670 100644 --- a/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx +++ b/packages/manager/src/components/CollapsibleTable/CollapsibleTable.tsx @@ -1,5 +1,3 @@ -import Paper from '@mui/material/Paper'; -import TableContainer from '@mui/material/TableContainer'; import * as React from 'react'; import { Table } from 'src/components/Table'; @@ -25,25 +23,23 @@ export const CollapsibleTable = (props: Props) => { const { TableItems, TableRowEmpty, TableRowHead } = props; return ( - -
  • - - {TableRowHead} - - - {TableItems.length === 0 && TableRowEmpty} - {TableItems.map((item) => { - return ( - - ); - })} - -
    - + + + {TableRowHead} + + + {TableItems.length === 0 && TableRowEmpty} + {TableItems.map((item) => { + return ( + + ); + })} + +
    ); }; diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.test.tsx b/packages/manager/src/components/ColorPalette/ColorPalette.test.tsx deleted file mode 100644 index a9f6024520e..00000000000 --- a/packages/manager/src/components/ColorPalette/ColorPalette.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { ColorPalette } from './ColorPalette'; - -describe('Color Palette', () => { - it('renders the Color Palette', () => { - const { getAllByText, getByText } = renderWithTheme(); - - // primary colors - getByText('Primary Colors'); - getByText('theme.palette.primary.main'); - const mainHash = getAllByText('#3683dc'); - expect(mainHash).toHaveLength(2); - getByText('theme.palette.primary.light'); - getByText('#4d99f1'); - getByText('theme.palette.primary.dark'); - getByText('#2466b3'); - getByText('theme.palette.text.primary'); - const primaryHash = getAllByText('#606469'); - expect(primaryHash).toHaveLength(3); - getByText('theme.color.headline'); - const headlineHash = getAllByText('#32363c'); - expect(headlineHash).toHaveLength(2); - getByText('theme.palette.divider'); - const dividerHash = getAllByText('#f4f4f4'); - expect(dividerHash).toHaveLength(2); - const whiteColor = getAllByText('theme.color.white'); - expect(whiteColor).toHaveLength(2); - const whiteHash = getAllByText('#fff'); - expect(whiteHash).toHaveLength(3); - - // etc - getByText('Etc.'); - getByText('theme.color.red'); - getByText('#ca0813'); - getByText('theme.color.orange'); - getByText('#ffb31a'); - getByText('theme.color.yellow'); - getByText('#fecf2f'); - getByText('theme.color.green'); - getByText('#00b159'); - getByText('theme.color.teal'); - getByText('#17cf73'); - getByText('theme.color.border2'); - getByText('#c5c6c8'); - getByText('theme.color.border3'); - getByText('#eee'); - getByText('theme.color.grey1'); - getByText('#abadaf'); - getByText('theme.color.grey2'); - getByText('#e7e7e7'); - getByText('theme.color.grey3'); - getByText('#ccc'); - getByText('theme.color.grey4'); - getByText('#8C929D'); - getByText('theme.color.grey5'); - getByText('#f5f5f5'); - getByText('theme.color.grey6'); - const borderGreyHash = getAllByText('#e3e5e8'); - expect(borderGreyHash).toHaveLength(3); - getByText('theme.color.grey7'); - getByText('#e9eaef'); - getByText('theme.color.grey8'); - getByText('#dbdde1'); - getByText('theme.color.grey9'); - const borderGrey9Hash = getAllByText('#f4f5f6'); - expect(borderGrey9Hash).toHaveLength(3); - getByText('theme.color.black'); - getByText('#222'); - getByText('theme.color.offBlack'); - getByText('#444'); - getByText('theme.color.boxShadow'); - getByText('#ddd'); - getByText('theme.color.boxShadowDark'); - getByText('#aaa'); - getByText('theme.color.blueDTwhite'); - getByText('theme.color.tableHeaderText'); - getByText('rgba(0, 0, 0, 0.54)'); - getByText('theme.color.drawerBackdrop'); - getByText('rgba(255, 255, 255, 0.5)'); - getByText('theme.color.label'); - getByText('#555'); - getByText('theme.color.disabledText'); - getByText('#c9cacb'); - getByText('theme.color.tagButton'); - getByText('#f1f7fd'); - getByText('theme.color.tagIcon'); - getByText('#7daee8'); - - // background colors - getByText('Background Colors'); - getByText('theme.bg.app'); - getByText('theme.bg.main'); - getByText('theme.bg.offWhite'); - getByText('#fbfbfb'); - getByText('theme.bg.lightBlue1'); - getByText('#f0f7ff'); - getByText('theme.bg.lightBlue2'); - getByText('#e5f1ff'); - getByText('theme.bg.white'); - getByText('theme.bg.tableHeader'); - getByText('#f9fafa'); - getByText('theme.bg.primaryNavPaper'); - getByText('#3a3f46'); - getByText('theme.bg.mainContentBanner'); - getByText('#33373d'); - getByText('theme.bg.bgPaper'); - getByText('#ffffff'); - getByText('theme.bg.bgAccessRow'); - getByText('#fafafa'); - getByText('theme.bg.bgAccessRowTransparentGradient'); - getByText('rgb(255, 255, 255, .001)'); - - // typography colors - getByText('Typography Colors'); - getByText('theme.textColors.linkActiveLight'); - getByText('#2575d0'); - getByText('theme.textColors.headlineStatic'); - getByText('theme.textColors.tableHeader'); - getByText('#888f91'); - getByText('theme.textColors.tableStatic'); - getByText('theme.textColors.textAccessTable'); - - // border colors - getByText('Border Colors'); - getByText('theme.borderColors.borderTypography'); - getByText('theme.borderColors.borderTable'); - getByText('theme.borderColors.divider'); - }); -}); diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.tsx b/packages/manager/src/components/ColorPalette/ColorPalette.tsx index a3bfaadb121..9404371eea9 100644 --- a/packages/manager/src/components/ColorPalette/ColorPalette.tsx +++ b/packages/manager/src/components/ColorPalette/ColorPalette.tsx @@ -1,12 +1,12 @@ -// eslint-disable-next-line no-restricted-imports import { useTheme } from '@mui/material'; import Grid from '@mui/material/Unstable_Grid2'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { Typography } from 'src/components/Typography'; +import type { Theme } from '@mui/material/styles'; + interface Color { alias: string; color: string; @@ -45,7 +45,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ /** * Add a new color to the palette, especially another tint of gray or blue, only after exhausting the option of using an existing color. * - * - Colors used in light mode are located in `foundations/light.ts + * - Colors used in light mode are located in `foundations/light.ts` * - Colors used in dark mode are located in `foundations/dark.ts` * * If a color does not exist in the current palette and is only used once, consider applying the color conditionally: @@ -102,7 +102,7 @@ export const ColorPalette = () => { { alias: 'theme.color.drawerBackdrop', color: theme.color.drawerBackdrop }, { alias: 'theme.color.label', color: theme.color.label }, { alias: 'theme.color.disabledText', color: theme.color.disabledText }, - { alias: 'theme.color.tagButton', color: theme.color.tagButton }, + { alias: 'theme.color.tagButton', color: theme.color.tagButtonBg }, { alias: 'theme.color.tagIcon', color: theme.color.tagIcon }, ]; diff --git a/packages/manager/src/components/Divider.tsx b/packages/manager/src/components/Divider.tsx index 6daa2d34fdb..cfd18a7fe5a 100644 --- a/packages/manager/src/components/Divider.tsx +++ b/packages/manager/src/components/Divider.tsx @@ -24,13 +24,6 @@ const StyledDivider = styled(_Divider, { 'dark', ]), })(({ theme, ...props }) => ({ - borderColor: props.dark - ? theme.color.border2 - : props.light - ? theme.name === 'light' - ? '#e3e5e8' - : '#2e3238' - : '', marginBottom: props.spacingBottom, marginTop: props.spacingTop, })); diff --git a/packages/manager/src/components/DocsLink/DocsLink.tsx b/packages/manager/src/components/DocsLink/DocsLink.tsx index fc13e6b3baf..aba71077a05 100644 --- a/packages/manager/src/components/DocsLink/DocsLink.tsx +++ b/packages/manager/src/components/DocsLink/DocsLink.tsx @@ -50,13 +50,10 @@ export const DocsLink = (props: DocsLinkProps) => { const StyledDocsLink = styled(Link, { label: 'StyledDocsLink', })(({ theme }) => ({ + ...theme.applyLinkStyles, '& svg': { marginRight: theme.spacing(), }, - '&:hover': { - color: theme.textColors.linkActiveLight, - textDecoration: 'underline', - }, alignItems: 'center', display: 'flex', fontFamily: theme.font.normal, diff --git a/packages/manager/src/components/EnhancedSelect/Select.styles.ts b/packages/manager/src/components/EnhancedSelect/Select.styles.ts index 9ffe05b76e6..597687971d1 100644 --- a/packages/manager/src/components/EnhancedSelect/Select.styles.ts +++ b/packages/manager/src/components/EnhancedSelect/Select.styles.ts @@ -1,6 +1,7 @@ -import { Theme } from '@mui/material/styles'; import { makeStyles } from 'tss-react/mui'; +import type { Theme } from '@mui/material/styles'; + // TODO jss-to-tss-react codemod: usages of this hook outside of this file will not be converted. export const useStyles = makeStyles()((theme: Theme) => ({ algoliaRoot: { @@ -225,6 +226,9 @@ export const useStyles = makeStyles()((theme: Theme) => ({ }, width: '100%', }, + '& .select-placeholder': { + color: theme.color.grey1, + }, '& [class*="MuiFormHelperText-error"]': { paddingBottom: theme.spacing(1), }, diff --git a/packages/manager/src/components/EnhancedSelect/Select.tsx b/packages/manager/src/components/EnhancedSelect/Select.tsx index 085210785f1..77f4ec721e5 100644 --- a/packages/manager/src/components/EnhancedSelect/Select.tsx +++ b/packages/manager/src/components/EnhancedSelect/Select.tsx @@ -1,18 +1,10 @@ -import { Theme, useTheme } from '@mui/material'; +import { useTheme } from '@mui/material'; import * as React from 'react'; -import ReactSelect, { - ActionMeta, - NamedProps as SelectProps, - ValueType, -} from 'react-select'; -import CreatableSelect, { - CreatableProps as CreatableSelectProps, -} from 'react-select/creatable'; +import ReactSelect from 'react-select'; +import CreatableSelect from 'react-select/creatable'; -import { TextFieldProps } from 'src/components/TextField'; import { convertToKebabCase } from 'src/utilities/convertToKebobCase'; -import { reactSelectStyles, useStyles } from './Select.styles'; import { DropdownIndicator } from './components/DropdownIndicator'; import Input from './components/Input'; import { LoadingIndicator } from './components/LoadingIndicator'; @@ -23,6 +15,16 @@ import NoOptionsMessage from './components/NoOptionsMessage'; import { Option } from './components/Option'; import Control from './components/SelectControl'; import { SelectPlaceholder as Placeholder } from './components/SelectPlaceholder'; +import { reactSelectStyles, useStyles } from './Select.styles'; + +import type { Theme } from '@mui/material'; +import type { + ActionMeta, + NamedProps as SelectProps, + ValueType, +} from 'react-select'; +import type { CreatableProps as CreatableSelectProps } from 'react-select/creatable'; +import type { TextFieldProps } from 'src/components/TextField'; export interface Item { data?: any; diff --git a/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx b/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx index 795daa1010f..e1b05f120ad 100644 --- a/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx +++ b/packages/manager/src/components/EntityHeader/EntityHeader.stories.tsx @@ -40,32 +40,14 @@ export const Default: Story = { variant: 'h2', }, render: (args) => { - const sxActionItem = { - '&:hover': { - backgroundColor: '#3683dc', - color: '#fff', - }, - color: '#2575d0', - fontFamily: '"LatoWeb", sans-serif', - fontSize: '0.875rem', - height: '34px', - minWidth: 'auto', - }; - return ( Chip / Progress Go Here - - - + + + should highlight text consistently 1`] = `

    Some markdown diff --git a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx index 94e0f7d417f..dba7a9ae442 100644 --- a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx +++ b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx @@ -52,7 +52,7 @@ export const InlineMenuAction = (props: InlineMenuActionProps) => { = { export default meta; const StyledWrapper = styled('div')(({ theme }) => ({ - backgroundColor: theme.color.grey2, - padding: theme.spacing(2), })); diff --git a/packages/manager/src/components/Notice/Notice.test.tsx b/packages/manager/src/components/Notice/Notice.test.tsx index cf12ad28ca2..e7d536cd907 100644 --- a/packages/manager/src/components/Notice/Notice.test.tsx +++ b/packages/manager/src/components/Notice/Notice.test.tsx @@ -58,7 +58,7 @@ describe('Notice Component', () => { it('applies variant prop', () => { const { container } = renderWithTheme(); - expect(container.firstChild).toHaveStyle('border-left: 5px solid #ca0813;'); + expect(container.firstChild).toHaveStyle('border-left: 5px solid #d63c42;'); }); it('displays icon for important notices', () => { diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts index c62e5c2d996..03912e910e5 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.styles.ts @@ -1,8 +1,9 @@ -import { Theme } from '@mui/material/styles'; import { makeStyles } from 'tss-react/mui'; import { SIDEBAR_WIDTH } from 'src/components/PrimaryNav/SideMenu'; +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()( (theme: Theme, _params, classes) => ({ active: { @@ -10,7 +11,7 @@ const useStyles = makeStyles()( opacity: 1, }, '& svg': { - color: theme.color.teal, + color: theme.palette.success.dark, }, backgroundImage: 'linear-gradient(98deg, #38584B 1%, #3A5049 166%)', textDecoration: 'none', @@ -22,12 +23,6 @@ const useStyles = makeStyles()( backgroundColor: 'rgba(0, 0, 0, 0.12)', color: '#222', }, - fadeContainer: { - display: 'flex', - flexDirection: 'column', - height: 'calc(100% - 90px)', - width: '100%', - }, linkItem: { '&.hiddenWhenCollapsed': { maxHeight: 36, @@ -70,8 +65,8 @@ const useStyles = makeStyles()( opacity: 1, }, '& svg': { - color: theme.color.teal, - fill: theme.color.teal, + color: theme.palette.success.dark, + fill: theme.palette.success.dark, }, [`& .${classes.linkItem}`]: { color: 'white', @@ -86,7 +81,6 @@ const useStyles = makeStyles()( minWidth: SIDEBAR_WIDTH, padding: '8px 13px', position: 'relative', - transition: theme.transitions.create(['background-color']), }, logo: { '& .akamai-logo-name': { @@ -96,7 +90,7 @@ const useStyles = makeStyles()( transition: 'width .1s linear', }, logoAkamaiCollapsed: { - background: theme.bg.primaryNavPaper, + background: theme.bg.appBar, width: 83, }, logoContainer: { @@ -111,6 +105,7 @@ const useStyles = makeStyles()( }, logoItemAkamai: { alignItems: 'center', + backgroundColor: theme.name === 'dark' ? theme.bg.appBar : undefined, display: 'flex', height: 50, paddingLeft: 13, diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index 4c7ec032ab0..180c6c5a32b 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -1,6 +1,6 @@ import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; -import { Link, LinkProps, useLocation } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import Account from 'src/assets/icons/account.svg'; import CloudPulse from 'src/assets/icons/cloudpulse.svg'; @@ -43,6 +43,8 @@ import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import useStyles from './PrimaryNav.styles'; import { linkIsActive } from './utils'; +import type { LinkProps } from 'react-router-dom'; + type NavEntity = | 'Account' | 'Account' @@ -343,7 +345,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { spacing={0} wrap="nowrap" > - + { -
    - {primaryLinkGroups.map((thisGroup, idx) => { - const filteredLinks = thisGroup.filter((thisLink) => !thisLink.hide); - if (filteredLinks.length === 0) { - return null; - } - return ( -
    - - {filteredLinks.map((thisLink) => { - const props = { - closeMenu, - isCollapsed, - key: thisLink.display, - locationPathname: location.pathname, - locationSearch: location.search, - ...thisLink, - }; - - // PrefetchPrimaryLink and PrimaryLink are two separate components because invocation of - // hooks cannot be conditional. is a wrapper around - // that includes the usePrefetch hook. - return thisLink.prefetchRequestFn && - thisLink.prefetchRequestCondition !== undefined ? ( - - ) : ( - - ); + + {primaryLinkGroups.map((thisGroup, idx) => { + const filteredLinks = thisGroup.filter((thisLink) => !thisLink.hide); + if (filteredLinks.length === 0) { + return null; + } + return ( +
    + ({ + borderColor: + theme.name === 'light' + ? theme.borderColors.dividerDark + : 'rgba(0, 0, 0, 0.19)', })} -
    - ); - })} -
    + className={classes.divider} + spacingBottom={11} + /> + {filteredLinks.map((thisLink) => { + const props = { + closeMenu, + isCollapsed, + key: thisLink.display, + locationPathname: location.pathname, + locationSearch: location.search, + ...thisLink, + }; + + // PrefetchPrimaryLink and PrimaryLink are two separate components because invocation of + // hooks cannot be conditional. is a wrapper around + // that includes the usePrefetch hook. + return thisLink.prefetchRequestFn && + thisLink.prefetchRequestCondition !== undefined ? ( + + ) : ( + + ); + })} +
    + ); + })}
    ); }; diff --git a/packages/manager/src/components/PrimaryNav/SideMenu.tsx b/packages/manager/src/components/PrimaryNav/SideMenu.tsx index e901df9883c..5be1241600f 100644 --- a/packages/manager/src/components/PrimaryNav/SideMenu.tsx +++ b/packages/manager/src/components/PrimaryNav/SideMenu.tsx @@ -66,7 +66,8 @@ const StyledDrawer = styled(Drawer, { shouldForwardProp: (prop) => prop !== 'collapse', })<{ collapse?: boolean }>(({ theme, ...props }) => ({ '& .MuiDrawer-paper': { - backgroundColor: theme.bg.primaryNavPaper, + backgroundColor: + theme.name === 'dark' ? theme.bg.appBar : theme.bg.primaryNavPaper, borderRight: 'none', boxShadow: 'none', height: '100%', diff --git a/packages/manager/src/components/SelectionCard/CardBase.styles.ts b/packages/manager/src/components/SelectionCard/CardBase.styles.ts index b07fe04d202..8c06a76beee 100644 --- a/packages/manager/src/components/SelectionCard/CardBase.styles.ts +++ b/packages/manager/src/components/SelectionCard/CardBase.styles.ts @@ -1,5 +1,5 @@ -import Grid from '@mui/material/Unstable_Grid2'; import { styled } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import type { CardBaseProps } from './CardBase'; @@ -18,15 +18,25 @@ export const CardBaseGrid = styled(Grid, { width: 5, }, '&:hover': { - backgroundColor: props.checked ? theme.bg.lightBlue2 : theme.bg.main, + backgroundColor: props.checked + ? theme.name === 'dark' + ? `rgba(0, 49, 77, .2)` + : `rgba(1, 116, 188, .2)` + : theme.bg.interactionBgPrimary, borderColor: props.checked ? theme.palette.primary.main - : theme.color.border2, + : theme.borderColors.borderHover, }, alignItems: 'center', - backgroundColor: props.checked ? theme.bg.lightBlue2 : theme.bg.offWhite, + backgroundColor: props.checked + ? theme.name === 'dark' + ? `rgba(0, 49, 77, .2)` + : `rgba(1, 116, 188, .2)` + : theme.bg.interactionBgPrimary, border: `1px solid ${theme.bg.main}`, - borderColor: props.checked ? theme.palette.primary.main : undefined, + borderColor: props.checked + ? theme.palette.primary.main + : theme.borderColors.divider, height: '100%', margin: 0, minHeight: 60, diff --git a/packages/manager/src/components/Snackbar/Snackbar.tsx b/packages/manager/src/components/Snackbar/Snackbar.tsx index 6bebab056c2..3a02f8ad24d 100644 --- a/packages/manager/src/components/Snackbar/Snackbar.tsx +++ b/packages/manager/src/components/Snackbar/Snackbar.tsx @@ -1,25 +1,30 @@ import { styled } from '@mui/material/styles'; -import { SnackbarProvider, SnackbarProviderProps } from 'notistack'; import { MaterialDesignContent } from 'notistack'; +import { SnackbarProvider } from 'notistack'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { CloseSnackbar } from './CloseSnackbar'; import type { Theme } from '@mui/material/styles'; +import type { SnackbarProviderProps } from 'notistack'; const StyledMaterialDesignContent = styled(MaterialDesignContent)( ({ theme }: { theme: Theme }) => ({ '&.notistack-MuiContent-error': { + backgroundColor: theme.palette.error.light, borderLeft: `6px solid ${theme.palette.error.dark}`, }, '&.notistack-MuiContent-info': { + backgroundColor: theme.palette.info.light, borderLeft: `6px solid ${theme.palette.primary.main}`, }, '&.notistack-MuiContent-success': { - borderLeft: `6px solid ${theme.palette.success.main}`, // corrected to palette.success + backgroundColor: theme.palette.success.light, + borderLeft: `6px solid ${theme.palette.success.dark}`, }, '&.notistack-MuiContent-warning': { + backgroundColor: theme.palette.warning.light, borderLeft: `6px solid ${theme.palette.warning.dark}`, }, }) @@ -28,7 +33,7 @@ const StyledMaterialDesignContent = styled(MaterialDesignContent)( const useStyles = makeStyles()((theme: Theme) => ({ root: { '& div': { - backgroundColor: `${theme.bg.white} !important`, + backgroundColor: `transparent`, color: theme.palette.text.primary, fontSize: '0.875rem', }, diff --git a/packages/manager/src/components/StatusIcon/StatusIcon.tsx b/packages/manager/src/components/StatusIcon/StatusIcon.tsx index fab623fe350..54d84bfee4f 100644 --- a/packages/manager/src/components/StatusIcon/StatusIcon.tsx +++ b/packages/manager/src/components/StatusIcon/StatusIcon.tsx @@ -53,16 +53,16 @@ const StyledDiv = styled(Box, { transition: theme.transitions.create(['color']), width: '16px', ...(props.status === 'active' && { - backgroundColor: theme.color.teal, + backgroundColor: theme.palette.success.dark, }), ...(props.status === 'inactive' && { backgroundColor: theme.color.grey8, }), ...(props.status === 'error' && { - backgroundColor: theme.color.red, + backgroundColor: theme.palette.error.dark, }), ...(!['active', 'error', 'inactive'].includes(props.status) && { - backgroundColor: theme.color.orange, + backgroundColor: theme.palette.warning.dark, }), ...(props.pulse && { animation: 'pulse 1.5s ease-in-out infinite', diff --git a/packages/manager/src/components/Table/Table.styles.ts b/packages/manager/src/components/Table/Table.styles.ts index f6b70032f91..87318bf2aaf 100644 --- a/packages/manager/src/components/Table/Table.styles.ts +++ b/packages/manager/src/components/Table/Table.styles.ts @@ -26,11 +26,9 @@ export const StyledTableWrapper = styled('div', { borderRight: 'none', }, backgroundColor: theme.bg.tableHeader, - borderBottom: `2px solid ${theme.borderColors.borderTable}`, - borderLeft: `1px solid ${theme.borderColors.borderTable}`, + borderBottom: `1px solid ${theme.borderColors.borderTable}`, borderRight: `1px solid ${theme.borderColors.borderTable}`, - borderTop: `2px solid ${theme.borderColors.borderTable}`, - color: theme.textColors.tableHeader, + borderTop: `1px solid ${theme.borderColors.borderTable}`, fontFamily: theme.font.bold, padding: '10px 15px', }, @@ -43,11 +41,4 @@ export const StyledTableWrapper = styled('div', { border: 0, }, }), - ...(props.rowHoverState && { - '& tbody tr': { - '&:hover': { - backgroundColor: theme.bg.lightBlue1, - }, - }, - }), })); diff --git a/packages/manager/src/components/TableRow/TableRow.styles.ts b/packages/manager/src/components/TableRow/TableRow.styles.ts index 78fc55f09a4..745ff361b3c 100644 --- a/packages/manager/src/components/TableRow/TableRow.styles.ts +++ b/packages/manager/src/components/TableRow/TableRow.styles.ts @@ -9,9 +9,6 @@ export const StyledTableRow = styled(_TableRow, { label: 'StyledTableRow', shouldForwardProp: omittedProps(['forceIndex']), })(({ theme, ...props }) => ({ - backgroundColor: theme.bg.bgPaper, - borderLeft: `1px solid ${theme.borderColors.borderTable}`, - borderRight: `1px solid ${theme.borderColors.borderTable}`, [theme.breakpoints.up('md')]: { boxShadow: `inset 3px 0 0 transparent`, }, @@ -38,14 +35,14 @@ export const StyledTableRow = styled(_TableRow, { ...(props.selected && { '& td': { '&:first-of-type': { - borderLeft: `1px solid ${theme.palette.primary.light}`, + borderLeft: `1px solid ${theme.borderColors.borderTable}`, }, - borderBottomColor: theme.palette.primary.light, - borderTop: `1px solid ${theme.palette.primary.light}`, + borderBottomColor: theme.borderColors.borderTable, + borderTop: `1px solid ${theme.borderColors.borderTable}`, position: 'relative', [theme.breakpoints.down('lg')]: { '&:last-child': { - borderRight: `1px solid ${theme.palette.primary.light}`, + borderRight: `1px solid ${theme.borderColors.borderTable}`, }, }, }, diff --git a/packages/manager/src/components/TableSortCell/TableSortCell.tsx b/packages/manager/src/components/TableSortCell/TableSortCell.tsx index 8cd56dd0452..0928cc1787b 100644 --- a/packages/manager/src/components/TableSortCell/TableSortCell.tsx +++ b/packages/manager/src/components/TableSortCell/TableSortCell.tsx @@ -18,7 +18,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ marginRight: 4, }, label: { - color: theme.textColors.tableHeader, fontSize: '.875rem', minHeight: 20, transition: 'none', diff --git a/packages/manager/src/components/Tabs/Tab.test.tsx b/packages/manager/src/components/Tabs/Tab.test.tsx index 38736410cdb..6463053b864 100644 --- a/packages/manager/src/components/Tabs/Tab.test.tsx +++ b/packages/manager/src/components/Tabs/Tab.test.tsx @@ -20,7 +20,7 @@ describe('Tab Component', () => { expect(tabElement).toHaveStyle(` display: inline-flex; - color: rgb(54, 131, 220); + color: rgb(0, 156, 222); `); }); diff --git a/packages/manager/src/components/Tabs/Tab.tsx b/packages/manager/src/components/Tabs/Tab.tsx index c940218ba07..ea65565187c 100644 --- a/packages/manager/src/components/Tabs/Tab.tsx +++ b/packages/manager/src/components/Tabs/Tab.tsx @@ -11,7 +11,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, '&:hover': { backgroundColor: theme.color.grey7, - color: theme.palette.primary.main, + color: theme.textColors.linkHover, }, alignItems: 'center', borderBottom: '2px solid transparent', @@ -29,7 +29,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, '&[data-reach-tab][data-selected]': { '&:hover': { - color: theme.palette.primary.main, + color: theme.textColors.linkHover, }, borderBottom: `3px solid ${theme.textColors.linkActiveLight}`, color: theme.textColors.headlineStatic, diff --git a/packages/manager/src/components/Tabs/TabList.tsx b/packages/manager/src/components/Tabs/TabList.tsx index 16a12a41296..0ae8e8a8714 100644 --- a/packages/manager/src/components/Tabs/TabList.tsx +++ b/packages/manager/src/components/Tabs/TabList.tsx @@ -23,9 +23,7 @@ export { TabList }; const StyledReachTabList = styled(ReachTabList)(({ theme }) => ({ '&[data-reach-tab-list]': { background: 'none !important', - boxShadow: `inset 0 -1px 0 ${ - theme.name === 'light' ? '#e3e5e8' : '#2e3238' - }`, + boxShadow: `inset 0 -1px 0 ${theme.borderColors.divider}`, marginBottom: theme.spacing(), [theme.breakpoints.down('lg')]: { overflowX: 'auto', diff --git a/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap b/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap index 947542f4e9b..7c5d66fe7d1 100644 --- a/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap +++ b/packages/manager/src/components/Tabs/__snapshots__/TabList.test.tsx.snap @@ -8,7 +8,7 @@ exports[`TabList component > renders TabList correctly 1`] = ` >
    diff --git a/packages/manager/src/components/Tag/Tag.styles.ts b/packages/manager/src/components/Tag/Tag.styles.ts index a54f9b67755..74ab54e1dd9 100644 --- a/packages/manager/src/components/Tag/Tag.styles.ts +++ b/packages/manager/src/components/Tag/Tag.styles.ts @@ -16,7 +16,6 @@ export const StyledChip = styled(Chip, { borderTopRightRadius: props.onDelete && 0, }, borderRadius: 4, - color: theme.name === 'light' ? '#3a3f46' : '#fff', fontFamily: theme.font.normal, maxWidth: 350, padding: '7px 10px', @@ -32,18 +31,19 @@ export const StyledChip = styled(Chip, { ['& .StyledDeleteButton']: { color: theme.color.tagIcon, }, - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, }, // Overrides MUI chip default styles so these appear as separate elements. '&:hover': { ['& .StyledDeleteButton']: { color: theme.color.tagIcon, }, - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, }, fontSize: '0.875rem', height: 30, padding: 0, + transition: 'none', ...(props.colorVariant === 'blue' && { '& > span': { '&:hover, &:focus': { @@ -58,15 +58,16 @@ export const StyledChip = styled(Chip, { ...(props.colorVariant === 'lightBlue' && { '& > span': { '&:focus': { - backgroundColor: theme.color.tagButton, - color: theme.color.black, + backgroundColor: theme.color.tagButtonBg, + color: theme.color.white, }, '&:hover': { - backgroundColor: theme.palette.primary.main, - color: 'white', + backgroundColor: theme.color.tagButtonBgHover, + color: theme.color.tagButtonTextHover, }, }, - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, + color: theme.color.tagButtonText, }), })); @@ -85,10 +86,9 @@ export const StyledDeleteButton = styled(StyledLinkButton, { }, '&:hover': { '& svg': { - color: 'white', + color: theme.color.tagIconHover, }, - backgroundColor: theme.palette.primary.main, - color: 'white', + backgroundColor: theme.color.buttonPrimaryHover, }, borderBottomRightRadius: 3, borderLeft: `1px solid ${theme.name === 'light' ? '#fff' : '#2e3238'}`, diff --git a/packages/manager/src/components/TagCell/TagCell.tsx b/packages/manager/src/components/TagCell/TagCell.tsx index 9226281fee9..c1433e9bd9e 100644 --- a/packages/manager/src/components/TagCell/TagCell.tsx +++ b/packages/manager/src/components/TagCell/TagCell.tsx @@ -225,7 +225,7 @@ const StyledIconButton = styled(IconButton)(({ theme }) => ({ backgroundColor: theme.palette.primary.main, color: '#ffff', }, - backgroundColor: theme.color.tagButton, + backgroundColor: theme.color.tagButtonBg, borderRadius: 0, color: theme.color.tagIcon, height: 30, diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx index 849a034e535..301c5787ca5 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.test.tsx @@ -56,7 +56,7 @@ describe('TextTooltip', () => { const displayText = getByText(props.displayText); - expect(displayText).toHaveStyle('color: rgb(54, 131, 220)'); + expect(displayText).toHaveStyle('color: rgb(0, 156, 222)'); expect(displayText).toHaveStyle('font-size: 18px'); }); diff --git a/packages/manager/src/components/TextTooltip/TextTooltip.tsx b/packages/manager/src/components/TextTooltip/TextTooltip.tsx index 5ce4c7f1598..25e43179479 100644 --- a/packages/manager/src/components/TextTooltip/TextTooltip.tsx +++ b/packages/manager/src/components/TextTooltip/TextTooltip.tsx @@ -82,10 +82,13 @@ export const TextTooltip = (props: TextTooltipProps) => { const StyledRootTooltip = styled(Tooltip, { label: 'StyledRootTooltip', })(({ theme }) => ({ + '&:hover': { + color: theme.textColors.linkHover, + }, borderRadius: 4, - color: theme.palette.primary.main, + color: theme.textColors.linkActiveLight, cursor: 'pointer', position: 'relative', - textDecoration: `underline dotted ${theme.palette.primary.main}`, + textDecoration: `underline dotted ${theme.textColors.linkActiveLight}`, textUnderlineOffset: 4, })); diff --git a/packages/manager/src/components/Tile/Tile.styles.ts b/packages/manager/src/components/Tile/Tile.styles.ts index ddeb5994f3f..a1a26d525ea 100644 --- a/packages/manager/src/components/Tile/Tile.styles.ts +++ b/packages/manager/src/components/Tile/Tile.styles.ts @@ -15,8 +15,8 @@ export const useStyles = makeStyles()( }, card: { alignItems: 'center', - backgroundColor: theme.color.white, - border: `1px solid ${theme.color.grey2}`, + backgroundColor: theme.bg.bgPaper, + border: `1px solid ${theme.borderColors.divider}`, display: 'flex', flexDirection: 'column', height: '100%', @@ -51,7 +51,7 @@ export const useStyles = makeStyles()( icon: { '& .insidePath': { fill: 'none', - stroke: '#3683DC', + stroke: theme.palette.primary.main, strokeLinejoin: 'round', strokeWidth: 1.25, }, diff --git a/packages/manager/src/components/TooltipIcon.tsx b/packages/manager/src/components/TooltipIcon.tsx index 0977bf75fb9..fc982c1a1b6 100644 --- a/packages/manager/src/components/TooltipIcon.tsx +++ b/packages/manager/src/components/TooltipIcon.tsx @@ -110,16 +110,16 @@ export const TooltipIcon = (props: TooltipIconProps) => { const sxRootStyle = { '&&': { - fill: '#888f91', - stroke: '#888f91', + fill: theme.color.grey4, + stroke: theme.color.grey4, strokeWidth: 0, }, '&:hover': { - color: '#3683dc', - fill: '#3683dc', - stroke: '#3683dc', + color: theme.palette.primary.main, + fill: theme.palette.primary.main, + stroke: theme.palette.primary.main, }, - color: '#888f91', + color: theme.color.grey4, height: 20, width: 20, }; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx index c0891c7d83d..9a7235d8729 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx @@ -4,7 +4,7 @@ import { Payment, getInvoiceItems, } from '@linode/api-v4/lib/account'; -import { Theme } from '@mui/material/styles'; +import { Theme, styled } from '@mui/material/styles'; import Grid from '@mui/material/Unstable_Grid2'; import { DateTime } from 'luxon'; import * as React from 'react'; @@ -335,9 +335,11 @@ export const BillingActivityPanel = (props: Props) => { }, [selectedTransactionType, combinedData]); return ( - +
    -
    + {`${isAkamaiCustomer ? 'Usage' : 'Billing & Payment'} History`} @@ -397,7 +399,7 @@ export const BillingActivityPanel = (props: Props) => { />
    -
    + { ); }; +const StyledBillingAndPaymentHistoryHeader = styled('div', { + name: 'BillingAndPaymentHistoryHeader', +})(({ theme }) => ({ + border: theme.name === 'dark' ? `1px solid ${theme.borderColors.divider}` : 0, + borderBottom: 0, +})); + // ============================================================================= // // ============================================================================= diff --git a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx index cfe7c4a3cad..e633213429d 100644 --- a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx +++ b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceTable.tsx @@ -1,8 +1,6 @@ import { InvoiceItem } from '@linode/api-v4/lib/account'; import { APIError } from '@linode/api-v4/lib/types'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; -import { makeStyles } from 'tss-react/mui'; import { Currency } from 'src/components/Currency'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; @@ -21,18 +19,6 @@ import { useRegionsQuery } from 'src/queries/regions/regions'; import { getInvoiceRegion } from '../PdfGenerator/utils'; -const useStyles = makeStyles()((theme: Theme) => ({ - table: { - '& thead th': { - '&:last-of-type': { - paddingRight: 15, - }, - borderBottom: `1px solid ${theme.borderColors.borderTable}`, - }, - border: `1px solid ${theme.borderColors.borderTable}`, - }, -})); - interface Props { errors?: APIError[]; items?: InvoiceItem[]; @@ -41,7 +27,6 @@ interface Props { } export const InvoiceTable = (props: Props) => { - const { classes } = useStyles(); const MIN_PAGE_SIZE = 25; const { @@ -157,7 +142,7 @@ export const InvoiceTable = (props: Props) => { }; return ( - +
    Description diff --git a/packages/manager/src/features/Help/Panels/PopularPosts.tsx b/packages/manager/src/features/Help/Panels/PopularPosts.tsx index 96d2d47feac..ec5f14daa7e 100644 --- a/packages/manager/src/features/Help/Panels/PopularPosts.tsx +++ b/packages/manager/src/features/Help/Panels/PopularPosts.tsx @@ -1,5 +1,5 @@ -import Grid from '@mui/material/Unstable_Grid2'; import { Theme } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -18,7 +18,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ margin: `${theme.spacing(6)} 0`, }, withSeparator: { - borderLeft: `1px solid ${theme.palette.divider}`, + borderLeft: `1px solid ${theme.borderColors.divider}`, paddingLeft: theme.spacing(4), [theme.breakpoints.down('sm')]: { borderLeft: 'none', diff --git a/packages/manager/src/features/Help/Panels/SearchPanel.tsx b/packages/manager/src/features/Help/Panels/SearchPanel.tsx index 3a2150403b3..49cb8a97d1c 100644 --- a/packages/manager/src/features/Help/Panels/SearchPanel.tsx +++ b/packages/manager/src/features/Help/Panels/SearchPanel.tsx @@ -22,7 +22,10 @@ const StyledRootContainer = styled(Paper, { label: 'StyledRootContainer', })(({ theme }) => ({ alignItems: 'center', - backgroundColor: theme.color.green, + backgroundColor: + theme.name === 'dark' + ? theme.palette.primary.light + : theme.palette.primary.dark, display: 'flex', flexDirection: 'column', justifyContent: 'center', @@ -36,7 +39,7 @@ const StyledRootContainer = styled(Paper, { const StyledH1Header = styled(H1Header, { label: 'StyledH1Header', })(({ theme }) => ({ - color: theme.name === 'dark' ? theme.color.black : theme.color.white, + color: theme.color.white, marginBottom: theme.spacing(), position: 'relative', textAlign: 'center', diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts b/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts index 47af5c1e3da..f59d4fb895c 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts +++ b/packages/manager/src/features/Linodes/LinodeEntityDetail.styles.ts @@ -122,7 +122,8 @@ export const StyledTable = styled(Table, { label: 'StyledTable' })( whiteSpace: 'nowrap', }, '& th': { - backgroundColor: theme.bg.app, + backgroundColor: + theme.name === 'light' ? theme.color.grey10 : theme.bg.app, borderBottom: `1px solid ${theme.bg.bgPaper}`, color: theme.textColors.textAccessTable, fontFamily: theme.font.bold, @@ -136,6 +137,7 @@ export const StyledTable = styled(Table, { label: 'StyledTable' })( '& tr': { height: 32, }, + border: 'none', tableLayout: 'fixed', }) ); diff --git a/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx b/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx index 398a5284d51..09ceb219071 100644 --- a/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx +++ b/packages/manager/src/features/Linodes/LinodeEntityDetailHeader.tsx @@ -134,10 +134,21 @@ export const LinodeEntityDetailHeader = ( formattedTransitionText !== formattedStatus; const sxActionItem = { + '&:focus': { + color: theme.color.white, + }, '&:hover': { - backgroundColor: theme.color.blue, - color: '#fff', + '&[aria-disabled="true"]': { + color: theme.color.disabledText, + }, + + color: theme.color.white, + }, + '&[aria-disabled="true"]': { + background: 'transparent', + color: theme.color.disabledText, }, + background: 'transparent', color: theme.textColors.linkActiveLight, fontFamily: theme.font.normal, fontSize: '0.875rem', @@ -197,14 +208,14 @@ export const LinodeEntityDetailHeader = ( onClick={() => handlers.onOpenPowerDialog(isRunning ? 'Power Off' : 'Power On') } - buttonType="secondary" + buttonType="primary" disabled={!(isRunning || isOffline) || isLinodesGrantReadOnly} sx={sxActionItem} > {isRunning ? 'Power Off' : 'Power On'}
    + + + {isFetching && } + {searchParseError && ( + + )} + setQuery('')} + size="small" + > + + + + ), + }} + tooltipText={ + type === 'Community' + ? 'Hint: try searching for a specific item by prepending your search term with "username:", "label:", or "description:"' + : undefined + } + hideLabel + label="Search" + onChange={debounce(400, (e) => setQuery(e.target.value))} + placeholder="Search StackScripts" + spellCheck={false} + value={query} + /> +
    @@ -153,6 +203,7 @@ export const StackScriptSelectionList = ({ type }: Props) => { stackscript={stackscript} /> ))} + {data?.pages[0].results === 0 && } {error && } {isLoading && } {(isFetchingNextPage || hasNextPage) && ( diff --git a/packages/search/README.md b/packages/search/README.md new file mode 100644 index 00000000000..56980bfa80f --- /dev/null +++ b/packages/search/README.md @@ -0,0 +1,43 @@ +# Search + +Search is a parser written with [Peggy](https://peggyjs.org) that takes a human readable search query and transforms it into a [Linode API v4 filter](https://techdocs.akamai.com/linode-api/reference/filtering-and-sorting). + +The goal of this package is to provide a shared utility that enables a powerful, scalable, and consistent search experience throughout Akamai Connected Cloud Manager. + +## Example + +### Search Query +``` +label: my-volume and size >= 20 +``` +### Resulting `X-Filter` +```json +{ + "+and": [ + { + "label": { + "+contains": "my-volume" + } + }, + { + "size": { + "+gte": 20 + } + } + ] +} +``` + +## Supported Operations + +| Operation | Aliases | Example | Description | +|-----------|----------------|--------------------------------|-----------------------------------------------------------------| +| `and` | `&`, `&&` | `label: prod and size > 20` | Performs a boolean *and* on two expressions | +| `or` | `|`, `||` | `label: prod or size > 20` | Performs a boolean *or* on two expressions | +| `>` | None | `size > 20` | Greater than | +| `<` | None | `size < 20` | Less than | +| `>=` | None | `size >= 20` | Great than or equal to | +| `<=` | None | `size <= 20` | Less than or equal to | +| `!` | `-` | `!label = my-linode-1` | Not equal to (does not work as a *not* for boolean expressions) | +| `=` | None | `label = my-linode-1` | Equal to | +| `:` | `~` | `label: my-linode` | Contains | diff --git a/packages/search/package.json b/packages/search/package.json new file mode 100644 index 00000000000..7445271aa83 --- /dev/null +++ b/packages/search/package.json @@ -0,0 +1,25 @@ +{ + "name": "@linode/search", + "version": "0.0.1", + "description": "Search query parser for Linode API filtering", + "type": "module", + "main": "src/search.ts", + "module": "src/search.ts", + "types": "src/search.ts", + "license": "Apache-2.0", + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "precommit": "tsc" + }, + "dependencies": { + "peggy": "^4.0.3" + }, + "peerDependencies": { + "@linode/api-v4": "*", + "vite": "*" + }, + "devDependencies": { + "vitest": "^1.6.0" + } +} diff --git a/packages/search/src/search.peggy b/packages/search/src/search.peggy new file mode 100644 index 00000000000..7a28192bd2b --- /dev/null +++ b/packages/search/src/search.peggy @@ -0,0 +1,99 @@ +start + = orQuery + +orQuery + = left:andQuery Or right:orQuery { return { "+or": [left, right] }; } + / andQuery + / DefaultQuery + +andQuery + = left:subQuery And right:andQuery { return { "+and": [left, right] }; } + / subQuery + +subQuery + = '(' ws* query:orQuery ws* ')' { return query; } + / EqualQuery + / ContainsQuery + / NotEqualQuery + / LessThanQuery + / LessThenOrEqualTo + / GreaterThanQuery + / GreaterThanOrEqualTo + +DefaultQuery + = input:String { + const keys = options.searchableFieldsWithoutOperator; + return { "+or": keys.map((key) => ({ [key]: { "+contains": input } })) }; + } + +EqualQuery + = key:FilterableField ws* Equal ws* value:Number { return { [key]: value }; } + / key:FilterableField ws* Equal ws* value:String { return { [key]: value }; } + +ContainsQuery + = key:FilterableField ws* Contains ws* value:String { return { [key]: { "+contains": value } }; } + +TagQuery + = "tag" ws* Equal ws* value:String { return { "tags": { "+contains": value } }; } + +NotEqualQuery + = Not key:FilterableField ws* Equal ws* value:String { return { [key]: { "+neq": value } }; } + +LessThanQuery + = key:FilterableField ws* Less ws* value:Number { return { [key]: { "+lt": value } }; } + +GreaterThanQuery + = key:FilterableField ws* Greater ws* value:Number { return { [key]: { "+gt": value } }; } + +GreaterThanOrEqualTo + = key:FilterableField ws* Gte ws* value:Number { return { [key]: { "+gte": value } }; } + +LessThenOrEqualTo + = key:FilterableField ws* Lte ws* value:Number { return { [key]: { "+lte": value } }; } + +Or + = ws+ 'or'i ws+ + / ws* '||' ws* + / ws* '|' ws* + +And + = ws+ 'and'i ws+ + / ws* '&&' ws* + / ws* '&' ws* + / ws + +Not + = '!' + / '-' + +Less + = '<' + +Greater + = '>' + +Gte + = '>=' + +Lte + = '<=' + +Equal + = "=" + +Contains + = "~" + / ":" + +FilterableField "filterable field" + = [a-zA-Z0-9\-\.]+ { return text(); } + +String "search value" + = [a-zA-Z0-9\-\.]+ { return text(); } + +Number "numeric search value" + = number:[0-9\.]+ { return parseFloat(number.join("")); } + / number:[0-9]+ { return parseInt(number.join(""), 10); } + +ws "whitespace" + = [ \t\r\n] \ No newline at end of file diff --git a/packages/search/src/search.test.ts b/packages/search/src/search.test.ts new file mode 100644 index 00000000000..0726cba626a --- /dev/null +++ b/packages/search/src/search.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; +import { getAPIFilterFromQuery } from './search'; + +describe("getAPIFilterFromQuery", () => { + it("handles +contains", () => { + const query = "label: my-linode"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + label: { "+contains": "my-linode" }, + }, + error: null, + }); + }); + + it("handles +eq with strings", () => { + const query = "label = my-linode"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + label: "my-linode", + }, + error: null, + }); + }); + + it("handles +eq with numbers", () => { + const query = "id = 100"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + id: 100, + }, + error: null, + }); + }); + + it("handles +lt", () => { + const query = "size < 20"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + size: { '+lt': 20 } + }, + error: null, + }); + }); + + it("handles +gt", () => { + const query = "size > 20"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + size: { '+gt': 20 } + }, + error: null, + }); + }); + + it("handles +gte", () => { + const query = "size >= 20"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + size: { '+gte': 20 } + }, + error: null, + }); + }); + + it("handles +lte", () => { + const query = "size <= 20"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + size: { '+lte': 20 } + }, + error: null, + }); + }); + + it("handles an 'and' search", () => { + const query = "label: my-linode-1 and tags: production"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + ["+and"]: [ + { label: { "+contains": "my-linode-1" } }, + { tags: { '+contains': "production" } }, + ], + }, + error: null, + }); + }); + + it("handles an 'or' search", () => { + const query = "label: prod or size >= 20"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + ["+or"]: [ + { label: { "+contains": "prod" } }, + { size: { '+gte': 20 } }, + ], + }, + error: null, + }); + }); + + it("handles nested queries", () => { + const query = "(label: prod and size >= 20) or (label: staging and size < 50)"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] })).toEqual({ + filter: { + ["+or"]: [ + { ["+and"]: [{ label: { '+contains': 'prod' } }, { size: { '+gte': 20 } }] }, + { ["+and"]: [{ label: { '+contains': 'staging' } }, { size: { '+lt': 50 } }] }, + ], + }, + error: null, + }); + }); + + it("returns a default query based on the 'defaultSearchKeys' provided", () => { + const query = "my-linode-1"; + + expect(getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: ['label', 'tags'] })).toEqual({ + filter: { + ["+or"]: [ + { label: { "+contains": "my-linode-1" } }, + { tags: { '+contains': "my-linode-1" } }, + ], + }, + error: null, + }); + }); + + it("returns an error for an incomplete search query", () => { + const query = "label: "; + + expect( + getAPIFilterFromQuery(query, { searchableFieldsWithoutOperator: [] }).error?.message + ).toEqual("Expected search value or whitespace but end of input found."); + }); +}); \ No newline at end of file diff --git a/packages/search/src/search.ts b/packages/search/src/search.ts new file mode 100644 index 00000000000..3cfb368c29a --- /dev/null +++ b/packages/search/src/search.ts @@ -0,0 +1,35 @@ +import { generate } from 'peggy'; +import type { Filter } from '@linode/api-v4'; +import grammar from './search.peggy?raw'; + +const parser = generate(grammar); + +interface Options { + /** + * Defines the API fields filtered against (currently using +contains) + * when the search query contains no operators. + * + * @example ['label', 'tags'] + */ + searchableFieldsWithoutOperator: string[]; +} + +/** + * Takes a search query and returns a valid X-Filter for Linode API v4 + */ +export function getAPIFilterFromQuery(query: string | null | undefined, options: Options) { + if (!query) { + return { filter: {}, error: null }; + } + + let filter: Filter = {}; + let error: SyntaxError | null = null; + + try { + filter = parser.parse(query, options); + } catch (e) { + error = e as SyntaxError; + } + + return { filter, error }; +} \ No newline at end of file diff --git a/packages/search/src/vite-env.d.ts b/packages/search/src/vite-env.d.ts new file mode 100644 index 00000000000..11f02fe2a00 --- /dev/null +++ b/packages/search/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/search/tsconfig.json b/packages/search/tsconfig.json new file mode 100644 index 00000000000..134d0055fe4 --- /dev/null +++ b/packages/search/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "forceConsistentCasingInFileNames": true, + "incremental": true + }, + "include": ["src"], +} diff --git a/yarn.lock b/yarn.lock index ecda695659e..c4094ed6d0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2502,6 +2502,13 @@ dependencies: hi-base32 "^0.5.0" +"@peggyjs/from-mem@1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@peggyjs/from-mem/-/from-mem-1.3.0.tgz#16470cf7dfa22fc75ca217a4e064a5f0c4e1111b" + integrity sha512-kzGoIRJjkg3KuGI4bopz9UvF3KguzfxalHRDEIdqEZUe45xezsQ6cx30e0RKuxPUexojQRBfu89Okn7f4/QXsw== + dependencies: + semver "7.6.0" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -6004,6 +6011,11 @@ commander@11.0.0: resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67" integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ== +commander@^12.1.0: + version "12.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" + integrity sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA== + commander@^4.0.0: version "4.1.1" resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" @@ -11144,6 +11156,15 @@ peek-stream@^1.1.0: duplexify "^3.5.0" through2 "^2.0.3" +peggy@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/peggy/-/peggy-4.0.3.tgz#7bcd47718483ab405c960350c5250e3e487dec74" + integrity sha512-v7/Pt6kGYsfXsCrfb52q7/yg5jaAwiVaUMAPLPvy4DJJU6Wwr72t6nDIqIDkGfzd1B4zeVuTnQT0RGeOhe/uSA== + dependencies: + "@peggyjs/from-mem" "1.3.0" + commander "^12.1.0" + source-map-generator "0.8.0" + pend@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" @@ -12451,7 +12472,7 @@ semver-compare@^1.0.0: resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== -"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0, semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4: +"semver@2 || 3 || 4 || 5", semver@7.6.0, semver@^5.5.0, semver@^5.6.0, semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.7, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4: version "7.6.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== @@ -12723,6 +12744,11 @@ sonic-forest@^1.0.0: dependencies: tree-dump "^1.0.0" +source-map-generator@0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/source-map-generator/-/source-map-generator-0.8.0.tgz#10d5ca0651e2c9302ea338739cbd4408849c5d00" + integrity sha512-psgxdGMwl5MZM9S3FWee4EgsEaIjahYV5AzGnwUvPhWeITz/j6rKpysQHlQ4USdxvINlb8lKfWGIXwfkrgtqkA== + source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" From d436114544480e58c88d4fefe4e820b3f7294bba Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 1 Jul 2024 11:45:43 -0400 Subject: [PATCH 133/163] upcoming: [M3-8282] - Prevent Linode Create v2 from toggling mid-creation (#10611) * hold flag value in state so it does not change * Added changeset: Prevent Linode Create v2 from toggling mid-creation --------- Co-authored-by: Banks Nussman --- .../pr-10611-upcoming-features-1719332278758.md | 5 +++++ packages/manager/src/features/Linodes/index.tsx | 11 +++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 packages/manager/.changeset/pr-10611-upcoming-features-1719332278758.md diff --git a/packages/manager/.changeset/pr-10611-upcoming-features-1719332278758.md b/packages/manager/.changeset/pr-10611-upcoming-features-1719332278758.md new file mode 100644 index 00000000000..c3bc81d8170 --- /dev/null +++ b/packages/manager/.changeset/pr-10611-upcoming-features-1719332278758.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Prevent Linode Create v2 from toggling mid-creation ([#10611](https://github.com/linode/manager/pull/10611)) diff --git a/packages/manager/src/features/Linodes/index.tsx b/packages/manager/src/features/Linodes/index.tsx index 9d8c7a7312a..db48cf3d1e3 100644 --- a/packages/manager/src/features/Linodes/index.tsx +++ b/packages/manager/src/features/Linodes/index.tsx @@ -1,4 +1,4 @@ -import * as React from 'react'; +import React, { useState } from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; @@ -25,13 +25,16 @@ const LinodesCreatev2 = React.lazy(() => const LinodesRoutes = () => { const flags = useFlags(); + + // Hold this feature flag in state so that the user's Linode creation + // isn't interupted when the flag is toggled. + const [isLinodeCreateV2Enabled] = useState(flags.linodeCreateRefactor); + return ( }> From 1211de45d4f1a0268accac6221e3aa3d982bec82 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 1 Jul 2024 17:47:33 -0400 Subject: [PATCH 134/163] upcoming: [M3-8310] - Add Validation to Linode Create v2 Marketplace Tab (#10629) * add validation for marketplace tab * Added changeset: Add Validation to Linode Create v2 Marketplace Tab --------- Co-authored-by: Banks Nussman --- ...r-10629-upcoming-features-1719848495146.md | 5 ++++ .../Tabs/Marketplace/AppSelect.tsx | 10 ++++++++ .../Linodes/LinodeCreatev2/resolvers.ts | 25 +++++++++++++++++-- .../Linodes/LinodeCreatev2/schemas.ts | 9 +++++++ 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-10629-upcoming-features-1719848495146.md diff --git a/packages/manager/.changeset/pr-10629-upcoming-features-1719848495146.md b/packages/manager/.changeset/pr-10629-upcoming-features-1719848495146.md new file mode 100644 index 00000000000..16ba99d5879 --- /dev/null +++ b/packages/manager/.changeset/pr-10629-upcoming-features-1719848495146.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Validation to Linode Create v2 Marketplace Tab ([#10629](https://github.com/linode/manager/pull/10629)) diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx index a804f3e21ff..43082645282 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Marketplace/AppSelect.tsx @@ -1,8 +1,10 @@ import React, { useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Box } from 'src/components/Box'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; @@ -11,6 +13,7 @@ import { useMarketplaceAppsQuery } from 'src/queries/stackscripts'; import { AppsList } from './AppsList'; import { categoryOptions } from './utilities'; +import type { LinodeCreateFormValues } from '../../utilities'; import type { AppCategory } from 'src/features/OneClickApps/types'; interface Props { @@ -23,6 +26,10 @@ interface Props { export const AppSelect = (props: Props) => { const { onOpenDetailsDrawer } = props; + const { + formState: { errors }, + } = useFormContext(); + const { isLoading } = useMarketplaceAppsQuery(true); const [query, setQuery] = useState(''); @@ -32,6 +39,9 @@ export const AppSelect = (props: Props) => { Select an App + {errors.stackscript_id?.message && ( + + )} = async ( @@ -52,6 +53,26 @@ export const stackscriptResolver: Resolver = async ( return { errors: {}, values }; }; +export const marketplaceResolver: Resolver = async ( + values, + context, + options +) => { + const transformedValues = getLinodeCreatePayload(structuredClone(values)); + + const { errors } = await yupResolver( + CreateLinodeFromMarketplaceAppSchema, + {}, + { mode: 'async', rawValues: true } + )(transformedValues, context, options); + + if (errors) { + return { errors, values }; + } + + return { errors: {}, values }; +}; + export const cloneResolver: Resolver = async ( values, context, @@ -107,6 +128,6 @@ export const linodeCreateResolvers: Record< 'Clone Linode': cloneResolver, Distributions: resolver, Images: resolver, - 'One-Click': stackscriptResolver, + 'One-Click': marketplaceResolver, StackScripts: stackscriptResolver, }; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/schemas.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/schemas.ts index d7e826c1a52..b97945a9868 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/schemas.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/schemas.ts @@ -27,3 +27,12 @@ export const CreateLinodeFromStackScriptSchema = CreateLinodeSchema.concat( stackscript_id: number().required('You must select a StackScript.'), }) ); + +/** + * Extends the Linode Create schema to make stackscript_id required for the Marketplace tab + */ +export const CreateLinodeFromMarketplaceAppSchema = CreateLinodeSchema.concat( + object({ + stackscript_id: number().required('You must select a Marketplace App.'), + }) +); From e612d3a25c11eaa7507ab8b90138330d91e4a3c4 Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-linode@users.noreply.github.com> Date: Tue, 2 Jul 2024 10:41:50 -0400 Subject: [PATCH 135/163] fix distirbuted regions not displaying in linode create flow (#10631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Looks like there was a change made to the `isDistributedRegionSupported` function which caused a regression in the distributed regions displaying in the Linode Create flow ## How to test 🧪 ### Prerequisites (How to setup test environment) - Ensure your account has the `new-dc-testing`, `edge_testing` and `edge_compute` customer tags ### Reproduction steps (How to reproduce the issue, if applicable) - Go to the remote dev environment and observe no Distributed regions in the Linode Create flow ### Verification steps (How to verify changes) - Either locally or in the preview link, go to the Linode Create flow. Distributed regions should be displaying again --- .../manager/src/components/RegionSelect/RegionSelect.utils.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx index 324accde315..7d7c21d8e9b 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.utils.tsx @@ -134,6 +134,7 @@ export const isDistributedRegionSupported = (createType: LinodeCreateType) => { 'Distributions', 'StackScripts', 'Images', + undefined, // /linodes/create route ]; return supportedDistributedRegionTypes.includes(createType); }; From 98ccce71ebaad8c1800ccb8131f79e873507496c Mon Sep 17 00:00:00 2001 From: Azure-akamai Date: Tue, 2 Jul 2024 12:37:09 -0400 Subject: [PATCH 136/163] test: [M3-6620] - Add assertions for created LKE cluster in Cypress LKE tests (#10593) * Add assertions for lke tests * update comments * Added changeset: Add assertions for created LKE cluster in Cypress LKE tests * update after reviews --- .../pr-10593-tests-1718736692043.md | 5 +++ .../e2e/core/kubernetes/lke-create.spec.ts | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 packages/manager/.changeset/pr-10593-tests-1718736692043.md diff --git a/packages/manager/.changeset/pr-10593-tests-1718736692043.md b/packages/manager/.changeset/pr-10593-tests-1718736692043.md new file mode 100644 index 00000000000..7fb89a5ac1d --- /dev/null +++ b/packages/manager/.changeset/pr-10593-tests-1718736692043.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add assertions for created LKE cluster in Cypress LKE tests ([#10593](https://github.com/linode/manager/pull/10593)) diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index fd495ecc0c0..bff073133a9 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -78,6 +78,7 @@ describe('LKE Cluster Creation', () => { * - Confirms that user is redirected to new LKE cluster summary page. * - Confirms that new LKE cluster summary page shows expected node pools. * - Confirms that new LKE cluster is shown on LKE clusters landing page. + * - Confirms that correct information is shown on the LKE cluster summary page */ it('can create an LKE cluster', () => { const clusterLabel = randomLabel(); @@ -114,6 +115,11 @@ describe('LKE Cluster Creation', () => { cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click(); + let totalCpu = 0; + let totalMemory = 0; + let totalStorage = 0; + let monthPrice = 0; + // Add a node pool for each randomly selected plan, and confirm that the // selected node pool plan is added to the checkout bar. clusterPlans.forEach((clusterPlan) => { @@ -150,7 +156,29 @@ describe('LKE Cluster Creation', () => { // instance of the pool appears in the checkout bar. cy.findAllByText(checkoutName).first().should('be.visible'); }); + + // Expected information on the LKE cluster summary page. + if (clusterPlan.size == 2 && clusterPlan.type == 'Linode') { + totalCpu = totalCpu + nodeCount * 1; + totalMemory = totalMemory + nodeCount * 2; + totalStorage = totalStorage + nodeCount * 50; + monthPrice = monthPrice + nodeCount * 12; + } + if (clusterPlan.size == 4 && clusterPlan.type == 'Linode') { + totalCpu = totalCpu + nodeCount * 2; + totalMemory = totalMemory + nodeCount * 4; + totalStorage = totalStorage + nodeCount * 80; + monthPrice = monthPrice + nodeCount * 24; + } + if (clusterPlan.size == 4 && clusterPlan.type == 'Dedicated') { + totalCpu = totalCpu + nodeCount * 2; + totalMemory = totalMemory + nodeCount * 4; + totalStorage = totalStorage + nodeCount * 80; + monthPrice = monthPrice + nodeCount * 36; + } }); + // $60.00/month for enabling HA control plane + const totalPrice = monthPrice + 60; // Create LKE cluster. cy.get('[data-testid="kube-checkout-bar"]') @@ -184,6 +212,15 @@ describe('LKE Cluster Creation', () => { const similarNodePoolCount = getSimilarPlans(clusterPlan, clusterPlans) .length; + //Confirm that the cluster created with the expected parameters. + cy.findAllByText(`${clusterRegion.label}`).should('be.visible'); + cy.findAllByText(`${totalCpu} CPU Cores`).should('be.visible'); + cy.findAllByText(`${totalMemory} GB RAM`).should('be.visible'); + cy.findAllByText(`${totalStorage} GB Storage`).should('be.visible'); + cy.findAllByText(`$${totalPrice}.00/month`).should('be.visible'); + cy.contains('Kubernetes API Endpoint').should('be.visible'); + cy.contains('linodelke.net:443').should('be.visible'); + cy.findAllByText(nodePoolLabel, { selector: 'h2' }) .should('have.length', similarNodePoolCount) .first() From 18cd9f5089fb7d3113e63a208cf3025fc8c9050c Mon Sep 17 00:00:00 2001 From: Azure-akamai Date: Tue, 2 Jul 2024 12:38:47 -0400 Subject: [PATCH 137/163] test: [M3-7318] - Combine vpc details page subset create, edit, and delete tests (#10612) * combine vpc details page subset create, edit, and delete tests * Added changeset: Combine VPC details page subnet create, edit, and delete Cypress tests --- .../pr-10612-tests-1719343415784.md | 5 + .../e2e/core/vpc/vpc-details-page.spec.ts | 155 ++++++------------ 2 files changed, 54 insertions(+), 106 deletions(-) create mode 100644 packages/manager/.changeset/pr-10612-tests-1719343415784.md diff --git a/packages/manager/.changeset/pr-10612-tests-1719343415784.md b/packages/manager/.changeset/pr-10612-tests-1719343415784.md new file mode 100644 index 00000000000..8c3b73cf285 --- /dev/null +++ b/packages/manager/.changeset/pr-10612-tests-1719343415784.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Combine VPC details page subnet create, edit, and delete Cypress tests ([#10612](https://github.com/linode/manager/pull/10612)) diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts index cc8341ef251..e7672f90c3c 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts @@ -120,87 +120,18 @@ describe('VPC details page', () => { cy.findByText('Create a private and isolated network'); }); - /** - * - Confirms Subnets section and table is shown on the VPC details page - * - Confirms UI flow when deleting a subnet from a VPC's detail page - */ - it('can delete a subnet from the VPC details page', () => { - const mockSubnet = subnetFactory.build({ - id: randomNumber(), - label: randomLabel(), - linodes: [], - }); - const mockVPC = vpcFactory.build({ - id: randomNumber(), - label: randomLabel(), - subnets: [mockSubnet], - }); - - const mockVPCAfterSubnetDeletion = vpcFactory.build({ - ...mockVPC, - subnets: [], - }); - - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - - mockGetVPC(mockVPC).as('getVPC'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); - mockDeleteSubnet(mockVPC.id, mockSubnet.id).as('deleteSubnet'); - - cy.visitWithLogin(`/vpcs/${mockVPC.id}`); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPC', '@getSubnets']); - - // confirm that vpc and subnet details get displayed - cy.findByText(mockVPC.label).should('be.visible'); - cy.findByText('Subnets (1)').should('be.visible'); - cy.findByText(mockSubnet.label).should('be.visible'); - - // confirm that subnet can be deleted and that page reflects changes - ui.actionMenu - .findByTitle(`Action menu for Subnet ${mockSubnet.label}`) - .should('be.visible') - .click(); - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); - - mockGetVPC(mockVPCAfterSubnetDeletion).as('getVPC'); - mockGetSubnets(mockVPC.id, []).as('getSubnets'); - - ui.dialog - .findByTitle(`Delete Subnet ${mockSubnet.label}`) - .should('be.visible') - .within(() => { - cy.findByLabelText('Subnet Label') - .should('be.visible') - .click() - .type(mockSubnet.label); - - ui.button - .findByTitle('Delete') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - cy.wait(['@deleteSubnet', '@getVPC', '@getSubnets']); - - // confirm that user should still be on VPC's detail page - // confirm there are no remaining subnets - cy.url().should('endWith', `/${mockVPC.id}`); - cy.findByText('Subnets (0)'); - cy.findByText('No Subnets are assigned.'); - cy.findByText(mockSubnet.label).should('not.exist'); - }); - /** * - Confirms UI flow when creating a subnet on a VPC's detail page. + * - Confirms UI flow for editing a subnet. + * - Confirms Subnets section and table is shown on the VPC details page. + * - Confirms UI flow when deleting a subnet from a VPC's detail page. */ - it('can create a subnet', () => { + it('can create, edit, and delete a subnet from the VPC details page', () => { + // create a subnet const mockSubnet = subnetFactory.build({ id: randomNumber(), label: randomLabel(), + linodes: [], }); const mockVPC = vpcFactory.build({ @@ -256,22 +187,8 @@ describe('VPC details page', () => { cy.findByText(mockVPC.label).should('be.visible'); cy.findByText('Subnets (1)').should('be.visible'); cy.findByText(mockSubnet.label).should('be.visible'); - }); - - /** - * - Confirms UI flow for editing a subnet - */ - it('can edit a subnet', () => { - const mockSubnet = subnetFactory.build({ - id: randomNumber(), - label: randomLabel(), - }); - const mockVPC = vpcFactory.build({ - id: randomNumber(), - label: randomLabel(), - subnets: [mockSubnet], - }); + // edit a subnet const mockEditedSubnet = subnetFactory.build({ ...mockSubnet, label: randomLabel(), @@ -282,22 +199,6 @@ describe('VPC details page', () => { subnets: [mockEditedSubnet], }); - mockAppendFeatureFlags({ - vpc: makeFeatureFlagData(true), - }).as('getFeatureFlags'); - - mockGetVPC(mockVPC).as('getVPC'); - mockGetFeatureFlagClientstream().as('getClientStream'); - mockGetSubnets(mockVPC.id, [mockSubnet]).as('getSubnets'); - - cy.visitWithLogin(`/vpcs/${mockVPC.id}`); - cy.wait(['@getFeatureFlags', '@getClientStream', '@getVPC', '@getSubnets']); - - // confirm that vpc and subnet details get displayed - cy.findByText(mockVPC.label).should('be.visible'); - cy.findByText('Subnets (1)').should('be.visible'); - cy.findByText(mockSubnet.label).should('be.visible'); - // confirm that subnet can be edited and that page reflects changes mockEditSubnet(mockVPC.id, mockEditedSubnet.id, mockEditedSubnet).as( 'editSubnet' @@ -336,5 +237,47 @@ describe('VPC details page', () => { cy.findByText(mockVPC.label).should('be.visible'); cy.findByText('Subnets (1)').should('be.visible'); cy.findByText(mockEditedSubnet.label).should('be.visible'); + + // delete a subnet + const mockVPCAfterSubnetDeletion = vpcFactory.build({ + ...mockVPC, + subnets: [], + }); + mockDeleteSubnet(mockVPC.id, mockEditedSubnet.id).as('deleteSubnet'); + + // confirm that subnet can be deleted and that page reflects changes + ui.actionMenu + .findByTitle(`Action menu for Subnet ${mockEditedSubnet.label}`) + .should('be.visible') + .click(); + ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + + mockGetVPC(mockVPCAfterSubnetDeletion).as('getVPC'); + mockGetSubnets(mockVPC.id, []).as('getSubnets'); + + ui.dialog + .findByTitle(`Delete Subnet ${mockEditedSubnet.label}`) + .should('be.visible') + .within(() => { + cy.findByLabelText('Subnet Label') + .should('be.visible') + .click() + .type(mockEditedSubnet.label); + + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@deleteSubnet', '@getVPC', '@getSubnets']); + + // confirm that user should still be on VPC's detail page + // confirm there are no remaining subnets + cy.url().should('endWith', `/${mockVPC.id}`); + cy.findByText('Subnets (0)'); + cy.findByText('No Subnets are assigned.'); + cy.findByText(mockEditedSubnet.label).should('not.exist'); }); }); From 3a207aec6a9998aa1a303a1121b139bffde204dc Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Tue, 2 Jul 2024 13:01:01 -0400 Subject: [PATCH 138/163] refactor: [M3-8299] - Fix Notification Toast in Dark Mode (#10637) Co-authored-by: Jaalah Ramos --- ...r-10637-upcoming-features-1719936723714.md | 5 ++++ .../src/components/Snackbar/Snackbar.tsx | 25 ++++++++++-------- .../manager/src/foundations/themes/dark.ts | 26 +++++++++++++++++++ .../manager/src/foundations/themes/index.ts | 25 +++++++++++++----- .../manager/src/foundations/themes/light.ts | 26 +++++++++++++++++++ 5 files changed, 90 insertions(+), 17 deletions(-) create mode 100644 packages/manager/.changeset/pr-10637-upcoming-features-1719936723714.md diff --git a/packages/manager/.changeset/pr-10637-upcoming-features-1719936723714.md b/packages/manager/.changeset/pr-10637-upcoming-features-1719936723714.md new file mode 100644 index 00000000000..70820ca8f4c --- /dev/null +++ b/packages/manager/.changeset/pr-10637-upcoming-features-1719936723714.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Fix Notification Toast in Dark Mode ([#10637](https://github.com/linode/manager/pull/10637)) diff --git a/packages/manager/src/components/Snackbar/Snackbar.tsx b/packages/manager/src/components/Snackbar/Snackbar.tsx index 3a02f8ad24d..e8ee778d665 100644 --- a/packages/manager/src/components/Snackbar/Snackbar.tsx +++ b/packages/manager/src/components/Snackbar/Snackbar.tsx @@ -12,31 +12,34 @@ import type { SnackbarProviderProps } from 'notistack'; const StyledMaterialDesignContent = styled(MaterialDesignContent)( ({ theme }: { theme: Theme }) => ({ '&.notistack-MuiContent-error': { - backgroundColor: theme.palette.error.light, - borderLeft: `6px solid ${theme.palette.error.dark}`, + backgroundColor: theme.notificationToast.error.backgroundColor, + borderLeft: theme.notificationToast.error.borderLeft, }, '&.notistack-MuiContent-info': { - backgroundColor: theme.palette.info.light, - borderLeft: `6px solid ${theme.palette.primary.main}`, + backgroundColor: theme.notificationToast.info.backgroundColor, + borderLeft: theme.notificationToast.info.borderLeft, }, '&.notistack-MuiContent-success': { - backgroundColor: theme.palette.success.light, - borderLeft: `6px solid ${theme.palette.success.dark}`, + backgroundColor: theme.notificationToast.success.backgroundColor, + borderLeft: theme.notificationToast.success.borderLeft, }, '&.notistack-MuiContent-warning': { - backgroundColor: theme.palette.warning.light, - borderLeft: `6px solid ${theme.palette.warning.dark}`, + backgroundColor: theme.notificationToast.warning.backgroundColor, + borderLeft: theme.notificationToast.warning.borderLeft, }, }) ); const useStyles = makeStyles()((theme: Theme) => ({ root: { - '& div': { - backgroundColor: `transparent`, - color: theme.palette.text.primary, + '& .notistack-MuiContent': { + color: theme.notificationToast.default.color, fontSize: '0.875rem', }, + '& .notistack-MuiContent-default': { + backgroundColor: theme.notificationToast.default.backgroundColor, + borderLeft: theme.notificationToast.default.borderLeft, + }, [theme.breakpoints.down('md')]: { '& .SnackbarItem-contentRoot': { flexWrap: 'nowrap', diff --git a/packages/manager/src/foundations/themes/dark.ts b/packages/manager/src/foundations/themes/dark.ts index 4ba65b5e97f..262d166cd5a 100644 --- a/packages/manager/src/foundations/themes/dark.ts +++ b/packages/manager/src/foundations/themes/dark.ts @@ -5,6 +5,7 @@ import { Color, Dropdown, Interaction, + NotificationToast, Select, TextField, } from '@linode/design-language-system/themes/dark'; @@ -96,6 +97,30 @@ export const customDarkModeOptions = { }, } as const; +export const notificationToast = { + default: { + backgroundColor: NotificationToast.Informative.Background, + borderLeft: `6px solid ${NotificationToast.Informative.Border}`, + color: NotificationToast.Text, + }, + error: { + backgroundColor: NotificationToast.Error.Background, + borderLeft: `6px solid ${NotificationToast.Error.Border}`, + }, + info: { + backgroundColor: NotificationToast.Informative.Background, + borderLeft: `6px solid ${NotificationToast.Informative.Border}`, + }, + success: { + backgroundColor: NotificationToast.Success.Background, + borderLeft: `6px solid ${NotificationToast.Success.Border}`, + }, + warning: { + backgroundColor: NotificationToast.Warning.Background, + borderLeft: `6px solid ${NotificationToast.Warning.Border}`, + }, +} as const; + const iconCircleAnimation = { '& .circle': { fill: primaryColors.main, @@ -826,6 +851,7 @@ export const darkTheme: ThemeOptions = { }, }, name: 'dark', + notificationToast, palette: { background: { default: customDarkModeOptions.bg.app, diff --git a/packages/manager/src/foundations/themes/index.ts b/packages/manager/src/foundations/themes/index.ts index 74c596ed386..113dd754683 100644 --- a/packages/manager/src/foundations/themes/index.ts +++ b/packages/manager/src/foundations/themes/index.ts @@ -1,18 +1,23 @@ import { createTheme } from '@mui/material/styles'; -import { latoWeb } from 'src/foundations/fonts'; // Themes & Brands import { darkTheme } from 'src/foundations/themes/dark'; -// Types & Interfaces -import { customDarkModeOptions } from 'src/foundations/themes/dark'; import { lightTheme } from 'src/foundations/themes/light'; -import { +import { deepMerge } from 'src/utilities/deepMerge'; + +import type { latoWeb } from 'src/foundations/fonts'; +// Types & Interfaces +import type { + customDarkModeOptions, + notificationToast as notificationToastDark, +} from 'src/foundations/themes/dark'; +import type { bg, borderColors, color, + notificationToast, textColors, } from 'src/foundations/themes/light'; -import { deepMerge } from 'src/utilities/deepMerge'; export type ThemeName = 'dark' | 'light'; @@ -38,9 +43,15 @@ type TextColors = MergeTypes; type LightModeBorderColors = typeof borderColors; type DarkModeBorderColors = typeof customDarkModeOptions.borderColors; - type BorderColors = MergeTypes; +type LightNotificationToast = typeof notificationToast; +type DarkNotificationToast = typeof notificationToastDark; +type NotificationToast = MergeTypes< + LightNotificationToast, + DarkNotificationToast +>; + /** * Augmenting the Theme and ThemeOptions. * This allows us to add custom fields to the theme. @@ -60,6 +71,7 @@ declare module '@mui/material/styles/createTheme' { graphs: any; inputStyles: any; name: ThemeName; + notificationToast: NotificationToast; textColors: TextColors; visually: any; } @@ -77,6 +89,7 @@ declare module '@mui/material/styles/createTheme' { graphs?: any; inputStyles?: any; name: ThemeName; + notificationToast?: NotificationToast; textColors?: DarkModeTextColors | LightModeTextColors; visually?: any; } diff --git a/packages/manager/src/foundations/themes/light.ts b/packages/manager/src/foundations/themes/light.ts index 94506a29c20..5b8dfa3338b 100644 --- a/packages/manager/src/foundations/themes/light.ts +++ b/packages/manager/src/foundations/themes/light.ts @@ -5,6 +5,7 @@ import { Color, Dropdown, Interaction, + NotificationToast, Select, } from '@linode/design-language-system'; @@ -100,6 +101,30 @@ export const borderColors = { dividerDark: Color.Neutrals[80], } as const; +export const notificationToast = { + default: { + backgroundColor: NotificationToast.Informative.Background, + borderLeft: `6px solid ${NotificationToast.Informative.Border}`, + color: NotificationToast.Text, + }, + error: { + backgroundColor: NotificationToast.Error.Background, + borderLeft: `6px solid ${NotificationToast.Error.Border}`, + }, + info: { + backgroundColor: NotificationToast.Informative.Background, + borderLeft: `6px solid ${NotificationToast.Informative.Border}`, + }, + success: { + backgroundColor: NotificationToast.Success.Background, + borderLeft: `6px solid ${NotificationToast.Success.Border}`, + }, + warning: { + backgroundColor: NotificationToast.Warning.Background, + borderLeft: `6px solid ${NotificationToast.Warning.Border}`, + }, +} as const; + const iconCircleAnimation = { '& .circle': { fill: primaryColors.main, @@ -1547,6 +1572,7 @@ export const lightTheme: ThemeOptions = { }, }, name: 'light', // @todo remove this because we leverage pallete.mode now + notificationToast, palette: { background: { default: bg.app, From 921c863b8bd665a5401d077a3fad06d750e9b18f Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 2 Jul 2024 13:25:08 -0400 Subject: [PATCH 139/163] upcoming: [M3-8066] - Debounce the Linode Create v2 `VLANSelect` (#10628) * debounce input value so less requests are made * add unit test * changeset and improve test name * add a default debounce of `500` ms --------- Co-authored-by: Banks Nussman --- ...r-10628-upcoming-features-1719846490755.md | 5 +++ .../manager/src/components/VLANSelect.tsx | 5 ++- .../src/hooks/useDebouncedValue.test.ts | 32 +++++++++++++++++++ .../manager/src/hooks/useDebouncedValue.ts | 17 ++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-10628-upcoming-features-1719846490755.md create mode 100644 packages/manager/src/hooks/useDebouncedValue.test.ts create mode 100644 packages/manager/src/hooks/useDebouncedValue.ts diff --git a/packages/manager/.changeset/pr-10628-upcoming-features-1719846490755.md b/packages/manager/.changeset/pr-10628-upcoming-features-1719846490755.md new file mode 100644 index 00000000000..4334c6b6240 --- /dev/null +++ b/packages/manager/.changeset/pr-10628-upcoming-features-1719846490755.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add debouncing to the Linode Create v2 `VLANSelect` ([#10628](https://github.com/linode/manager/pull/10628)) diff --git a/packages/manager/src/components/VLANSelect.tsx b/packages/manager/src/components/VLANSelect.tsx index f95f584a680..560b744ee19 100644 --- a/packages/manager/src/components/VLANSelect.tsx +++ b/packages/manager/src/components/VLANSelect.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from 'react'; +import { useDebouncedValue } from 'src/hooks/useDebouncedValue'; import { useVLANsInfiniteQuery } from 'src/queries/vlans'; import { Autocomplete } from './Autocomplete/Autocomplete'; @@ -59,9 +60,11 @@ export const VLANSelect = (props: Props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); + const debouncedInputValue = useDebouncedValue(inputValue); + const apiFilter = getVLANSelectFilter({ defaultFilter: filter, - inputValue, + inputValue: debouncedInputValue, }); const { diff --git a/packages/manager/src/hooks/useDebouncedValue.test.ts b/packages/manager/src/hooks/useDebouncedValue.test.ts new file mode 100644 index 00000000000..cf560b93944 --- /dev/null +++ b/packages/manager/src/hooks/useDebouncedValue.test.ts @@ -0,0 +1,32 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useDebouncedValue } from './useDebouncedValue'; + +describe('useDebouncedValue', () => { + it('debounces the provided value by the given delay', () => { + vi.useFakeTimers(); + + const { rerender, result } = renderHook( + ({ value }) => useDebouncedValue(value, 500), + { initialProps: { value: 'test' } } + ); + + expect(result.current).toBe('test'); + + rerender({ value: 'test-1' }); + + expect(result.current).toBe('test'); + + act(() => { + vi.advanceTimersByTime(400); + }); + + expect(result.current).toBe('test'); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(result.current).toBe('test-1'); + }); +}); diff --git a/packages/manager/src/hooks/useDebouncedValue.ts b/packages/manager/src/hooks/useDebouncedValue.ts new file mode 100644 index 00000000000..526ed0a470a --- /dev/null +++ b/packages/manager/src/hooks/useDebouncedValue.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +export const useDebouncedValue = (value: T, delay: number = 500) => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +}; From 34c9d7bc80d0a3130f3f214e00b9e8aaed40bed5 Mon Sep 17 00:00:00 2001 From: cliu-akamai <126020611+cliu-akamai@users.noreply.github.com> Date: Tue, 2 Jul 2024 13:31:03 -0400 Subject: [PATCH 140/163] test: [M3-7957] - Add Cypress integration test for SSH key update and delete (#10542) * M3-7957 Cypress integration test for SSH key update and delete * Fixed comments * Added changeset: Cypress integration test for SSH key update and delete --- .../pr-10542-tests-1719335118630.md | 5 + .../cypress/e2e/core/account/ssh-keys.spec.ts | 179 ++++++++++++++++++ .../cypress/support/intercepts/profile.ts | 26 +++ 3 files changed, 210 insertions(+) create mode 100644 packages/manager/.changeset/pr-10542-tests-1719335118630.md diff --git a/packages/manager/.changeset/pr-10542-tests-1719335118630.md b/packages/manager/.changeset/pr-10542-tests-1719335118630.md new file mode 100644 index 00000000000..b5a04fd34d0 --- /dev/null +++ b/packages/manager/.changeset/pr-10542-tests-1719335118630.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Cypress integration test for SSH key update and delete ([#10542](https://github.com/linode/manager/pull/10542)) diff --git a/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts b/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts index 59b34a59101..d0cf29ac00d 100644 --- a/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts +++ b/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts @@ -2,7 +2,9 @@ import { sshKeyFactory } from 'src/factories'; import { mockCreateSSHKey, mockCreateSSHKeyError, + mockDeleteSSHKey, mockGetSSHKeys, + mockUpdateSSHKey, } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { randomLabel, randomString } from 'support/util/random'; @@ -169,4 +171,181 @@ describe('SSH keys', () => { // When the API responds with an error (e.g. a 400 response), the API response error message is displayed on the form cy.findByText(errorMessage); }); + + /* + * - Validates SSH key update flow using mock data. + * - Confirms that the drawer opens when clicking. + * - Confirms that a form validation error appears when the label is not present. + * - Confirms UI flow when user updates an SSH key. + */ + it('updates an SSH key via Profile page as expected', () => { + const randomKey = randomString(400, { + uppercase: true, + lowercase: true, + numbers: true, + spaces: false, + symbols: false, + }); + const mockSSHKey = sshKeyFactory.build({ + label: randomLabel(), + ssh_key: `ssh-rsa e2etestkey${randomKey} e2etest@linode`, + }); + const newSSHKeyLabel = randomLabel(); + const modifiedSSHKey = sshKeyFactory.build({ + ...mockSSHKey, + label: newSSHKeyLabel, + }); + + mockGetSSHKeys([mockSSHKey]).as('getSSHKeys'); + + // Navigate to SSH key landing page. + cy.visitWithLogin('/profile/keys'); + cy.wait('@getSSHKeys'); + + // When a user clicks "Edit" button on SSH key landing page (/profile/keys), the "Edit SSH Key" drawer opens + ui.button + .findByTitle('Edit') + .should('be.visible') + .should('be.enabled') + .click(); + ui.drawer + .findByTitle(`Edit SSH Key ${mockSSHKey.label}`) + .should('be.visible') + .within(() => { + // When the label is unchanged, the 'Save' button is diabled + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.disabled'); + + // When a user tries to update an SSH key without a label, a form validation error appears + cy.get('[id="label"]').clear(); + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('Label is required.'); + + // SSH label is not modified when the operation is cancelled + cy.get('[id="label"]').clear().type(newSSHKeyLabel); + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + cy.findAllByText(mockSSHKey.label).should('be.visible'); + + mockGetSSHKeys([modifiedSSHKey]).as('getSSHKeys'); + mockUpdateSSHKey(mockSSHKey.id, modifiedSSHKey).as('updateSSHKey'); + + ui.button + .findByTitle('Edit') + .should('be.visible') + .should('be.enabled') + .click(); + ui.drawer + .findByTitle(`Edit SSH Key ${mockSSHKey.label}`) + .should('be.visible') + .within(() => { + // Update a new ssh key + cy.get('[id="label"]').clear().type(newSSHKeyLabel); + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@updateSSHKey', '@getSSHKeys']); + + // When a user updates an SSH key, a toast notification appears that says "Successfully updated SSH key." + ui.toast.assertMessage('Successfully updated SSH key.'); + + // When a user updates an SSH key, the list of SSH keys for each user updates to show the new key for the signed in user + cy.findAllByText(modifiedSSHKey.label).should('be.visible'); + }); + + /* + * - Vaildates SSH key delete flow using mock data. + * - Confirms that the dialog opens when clicking. + * - Confirms UI flow when user deletes an SSH key. + */ + it('deletes an SSH key via Profile page as expected', () => { + const mockSSHKeys = sshKeyFactory.buildList(2); + + mockGetSSHKeys(mockSSHKeys).as('getSSHKeys'); + + // Navigate to SSH key landing page. + cy.visitWithLogin('/profile/keys'); + cy.wait('@getSSHKeys'); + + mockDeleteSSHKey(mockSSHKeys[0].id).as('deleteSSHKey'); + mockGetSSHKeys([mockSSHKeys[1]]).as('getUpdatedSSHKeys'); + + // When a user clicks "Delete" button on SSH key landing page (/profile/keys), the "Delete SSH Key" dialog opens + cy.findAllByText(`${mockSSHKeys[0].label}`) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + ui.dialog + .findByTitle('Delete SSH Key') + .should('be.visible') + .within(() => { + cy.findAllByText( + `Are you sure you want to delete SSH key ${mockSSHKeys[0].label}?` + ).should('be.visible'); + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@deleteSSHKey', '@getUpdatedSSHKeys']); + + // When a user deletes an SSH key, the SSH key is removed from the list + cy.findAllByText(mockSSHKeys[0].label).should('not.exist'); + + mockDeleteSSHKey(mockSSHKeys[1].id).as('deleteSSHKey'); + mockGetSSHKeys([]).as('getUpdatedSSHKeys'); + + // When a user clicks "Delete" button on SSH key landing page (/profile/keys), the "Delete SSH Key" dialog opens + cy.findAllByText(`${mockSSHKeys[1].label}`) + .should('be.visible') + .closest('tr') + .within(() => { + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + ui.dialog + .findByTitle('Delete SSH Key') + .should('be.visible') + .within(() => { + cy.findAllByText( + `Are you sure you want to delete SSH key ${mockSSHKeys[1].label}?` + ).should('be.visible'); + ui.button + .findByTitle('Delete') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait(['@deleteSSHKey', '@getUpdatedSSHKeys']); + + // When a user deletes the last SSH key, the list of SSH keys updates to show "No items to display." + cy.findAllByText(mockSSHKeys[1].label).should('not.exist'); + cy.findAllByText('No items to display.').should('be.visible'); + }); }); diff --git a/packages/manager/cypress/support/intercepts/profile.ts b/packages/manager/cypress/support/intercepts/profile.ts index 11aee4b2c76..e2493f09868 100644 --- a/packages/manager/cypress/support/intercepts/profile.ts +++ b/packages/manager/cypress/support/intercepts/profile.ts @@ -458,3 +458,29 @@ export const mockCreateSSHKeyError = ( makeErrorResponse(errorMessage, status) ); }; + +/** + * Intercepts PUT request to update an SSH key and mocks response. + * + * @param sshKeyId - The SSH key ID to update + * @param sshKey - An SSH key with which to update. + * + * @returns Cypress chainable. + */ +export const mockUpdateSSHKey = ( + sshKeyId: number, + sshKey: SSHKey +): Cypress.Chainable => { + return cy.intercept('PUT', apiMatcher(`profile/sshkeys/${sshKeyId}`), sshKey); +}; + +/** + * Intercepts DELETE request to delete an SSH key and mocks response. + * + * @param sshKeyId - The SSH key ID to delete + * + * @returns Cypress chainable. + */ +export const mockDeleteSSHKey = (sshKeyId: number): Cypress.Chainable => { + return cy.intercept('DELETE', apiMatcher(`profile/sshkeys/${sshKeyId}`), {}); +}; From ecbc63fcee6a21db92b15dc3e21facba80216ba7 Mon Sep 17 00:00:00 2001 From: Dajahi Wiley <114682940+dwiley-akamai@users.noreply.github.com> Date: Tue, 2 Jul 2024 13:34:44 -0400 Subject: [PATCH 141/163] =?UTF-8?q?fix:=20[M3-7590,=20M3-7886]=20=E2=80=93?= =?UTF-8?q?=20Improve=20UX=20for=20Linode=20Resize=20dialog=20when=20linod?= =?UTF-8?q?e=20data=20is=20being=20loaded=20or=20there=20is=20a=20form=20e?= =?UTF-8?q?rror=20(#10618)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pr-10618-fixed-1719521792524.md | 5 + .../LinodeResize/LinodeResize.tsx | 295 +++++++++--------- 2 files changed, 156 insertions(+), 144 deletions(-) create mode 100644 packages/manager/.changeset/pr-10618-fixed-1719521792524.md diff --git a/packages/manager/.changeset/pr-10618-fixed-1719521792524.md b/packages/manager/.changeset/pr-10618-fixed-1719521792524.md new file mode 100644 index 00000000000..ebceb5f44ad --- /dev/null +++ b/packages/manager/.changeset/pr-10618-fixed-1719521792524.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Linode Resize dialog UX when linode data is loading or there is an error ([#10618](https://github.com/linode/manager/pull/10618)) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx index 6d47d62e872..877137b03ff 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx @@ -1,7 +1,3 @@ -import { - MigrationTypes, - ResizeLinodePayload, -} from '@linode/api-v4/lib/linodes'; import { useTheme } from '@mui/material/styles'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; @@ -10,6 +6,7 @@ import * as React from 'react'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { Checkbox } from 'src/components/Checkbox'; +import { CircleProgress } from 'src/components/CircleProgress/CircleProgress'; import { Dialog } from 'src/components/Dialog/Dialog'; import { Divider } from 'src/components/Divider'; import { Link } from 'src/components/Link'; @@ -30,7 +27,7 @@ import { usePreferences } from 'src/queries/profile/preferences'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { useAllTypes } from 'src/queries/types'; import { extendType } from 'src/utilities/extendType'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; +import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; import { HostMaintenanceError } from '../HostMaintenanceError'; import { LinodePermissionsError } from '../LinodePermissionsError'; @@ -41,6 +38,11 @@ import { } from './LinodeResize.utils'; import { UnifiedMigrationPanel } from './LinodeResizeUnifiedMigrationPanel'; +import type { + MigrationTypes, + ResizeLinodePayload, +} from '@linode/api-v4/lib/linodes'; + interface Props { linodeId?: number; linodeLabel?: string; @@ -57,7 +59,7 @@ export const LinodeResize = (props: Props) => { const { linodeId, onClose, open } = props; const theme = useTheme(); - const { data: linode } = useLinodeQuery( + const { data: linode, isLoading: isLinodeDataLoading } = useLinodeQuery( linodeId ?? -1, linodeId !== undefined && open ); @@ -77,6 +79,8 @@ export const LinodeResize = (props: Props) => { const [hasResizeError, setHasResizeError] = React.useState(false); + const formRef = React.useRef(null); + const { error: resizeError, isLoading, @@ -125,6 +129,7 @@ export const LinodeResize = (props: Props) => { }); onClose(); }, + validate: () => scrollErrorIntoViewV2(formRef), }); React.useEffect(() => { @@ -154,8 +159,6 @@ export const LinodeResize = (props: Props) => { React.useEffect(() => { if (resizeError) { setHasResizeError(true); - // Set to "block: end" since the sticky header would otherwise interfere. - scrollErrorIntoView(undefined, { block: 'end' }); } }, [resizeError]); @@ -190,148 +193,152 @@ export const LinodeResize = (props: Props) => { maxWidth="md" onClose={onClose} open={open} - title={`Resize Linode ${linode?.label}`} + title={`Resize Linode ${linode?.label ?? ''}`} > -
    - {isLinodesGrantReadOnly && } - {hostMaintenance && } - {disksError && ( - - )} - {hasResizeError && {error}} - - If you’re expecting a temporary burst of traffic to your - website, or if you’re not using your Linode as much as you - thought, you can temporarily or permanently resize your Linode to a - different plan.{' '} - - Learn more. - - - - div': { - padding: 0, - }, - marginBottom: theme.spacing(3), - marginTop: theme.spacing(5), - }} - > - formik.setFieldValue('type', type)} - regionsData={regionsData} - selectedId={formik.values.type} - selectedRegionID={linode?.region} - types={currentTypes.map(extendType)} - /> - - - - Auto Resize Disk - {disksError ? ( - + ) : ( + + {isLinodesGrantReadOnly && } + {hostMaintenance && } + {disksError && ( + - ) : isSmaller ? ( - {error}} + + If you’re expecting a temporary burst of traffic to your + website, or if you’re not using your Linode as much as you + thought, you can temporarily or permanently resize your Linode to a + different plan.{' '} + + Learn more. + + + + div': { + padding: 0, + }, + marginBottom: theme.spacing(3), + marginTop: theme.spacing(5), + }} + > + formik.setFieldValue('type', type)} + regionsData={regionsData} + selectedId={formik.values.type} + selectedRegionID={linode?.region} + types={currentTypes.map(extendType)} /> - ) : !_shouldEnableAutoResizeDiskOption ? ( - + + + Auto Resize Disk + {disksError ? ( + + ) : isSmaller ? ( + + ) : !_shouldEnableAutoResizeDiskOption ? ( + - ) : null} - - - formik.setFieldValue('allow_auto_disk_resize', checked) - } - text={ - - Would you like{' '} - {_shouldEnableAutoResizeDiskOption ? ( - {diskToResize} - ) : ( - 'your disk' - )}{' '} - to be automatically scaled with this Linode’s new size?{' '} -
    - We recommend you keep this option enabled when available. -
    - } - disabled={!_shouldEnableAutoResizeDiskOption || isSmaller} - /> - - - - To confirm these changes, type the label of the Linode ( - {linode?.label}) in the field below: - + status="help" + /> + ) : null} +
    + - - - - - + text={ + + Would you like{' '} + {_shouldEnableAutoResizeDiskOption ? ( + {diskToResize} + ) : ( + 'your disk' + )}{' '} + to be automatically scaled with this Linode’s new size?{' '} +
    + We recommend you keep this option enabled when available. +
    + } + disabled={!_shouldEnableAutoResizeDiskOption || isSmaller} + /> + + + + To confirm these changes, type the label of the Linode ( + {linode?.label}) in the field below: + + } + hideLabel + label="Linode Label" + onChange={setConfirmationText} + textFieldStyle={{ marginBottom: 16 }} + title="Confirm" + typographyStyle={{ marginBottom: 8 }} + value={confirmationText} + visible={preferences?.type_to_confirm} + /> + + + + + + )} ); }; From bae20b838accb23c4275f680109ec073f77e736f Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Tue, 2 Jul 2024 13:36:24 -0400 Subject: [PATCH 142/163] upcoming: [M3-8267] - Add Marketplace Cluster pricing support to Linode Create v2 (#10623) * add cluster pricing to summary * Added changeset: Add Marketplace Cluster pricing support to Linode Create v2 * clean up and make more robust --------- Co-authored-by: Banks Nussman --- ...r-10623-upcoming-features-1719595144995.md | 5 +++ .../{ => Summary}/Summary.test.tsx | 35 +++++++++++++++- .../LinodeCreatev2/{ => Summary}/Summary.tsx | 14 ++++--- .../LinodeCreatev2/Summary/utilities.test.ts | 33 +++++++++++++++ .../LinodeCreatev2/Summary/utilities.ts | 42 +++++++++++++++++++ .../features/Linodes/LinodeCreatev2/index.tsx | 6 +-- 6 files changed, 124 insertions(+), 11 deletions(-) create mode 100644 packages/manager/.changeset/pr-10623-upcoming-features-1719595144995.md rename packages/manager/src/features/Linodes/LinodeCreatev2/{ => Summary}/Summary.test.tsx (87%) rename packages/manager/src/features/Linodes/LinodeCreatev2/{ => Summary}/Summary.tsx (93%) create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.test.ts create mode 100644 packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.ts diff --git a/packages/manager/.changeset/pr-10623-upcoming-features-1719595144995.md b/packages/manager/.changeset/pr-10623-upcoming-features-1719595144995.md new file mode 100644 index 00000000000..7ac2be855fa --- /dev/null +++ b/packages/manager/.changeset/pr-10623-upcoming-features-1719595144995.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Marketplace Cluster pricing support to Linode Create v2 ([#10623](https://github.com/linode/manager/pull/10623)) diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.test.tsx similarity index 87% rename from packages/manager/src/features/Linodes/LinodeCreatev2/Summary.test.tsx rename to packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.test.tsx index 6663aa287a7..05994a8951b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.test.tsx @@ -85,7 +85,7 @@ describe('Linode Create v2 Summary', () => { await findByText(region.label); }); - it('should render a plan (type) label if a type is selected', async () => { + it('should render a plan (type) label if a region and type are selected', async () => { const type = typeFactory.build(); server.use( @@ -96,7 +96,9 @@ describe('Linode Create v2 Summary', () => { const { findByText } = renderWithThemeAndHookFormContext({ component: , - useFormOptions: { defaultValues: { type: type.id } }, + useFormOptions: { + defaultValues: { region: 'fake-region', type: type.id }, + }, }); await findByText(type.label); @@ -233,4 +235,33 @@ describe('Linode Create v2 Summary', () => { expect(getByText('Encrypted')).toBeVisible(); }); + + it('should render correct pricing for Marketplace app cluster deployments', async () => { + const type = typeFactory.build({ + price: { hourly: 0.5, monthly: 2 }, + }); + + server.use( + http.get('*/v4/linode/types/*', () => { + return HttpResponse.json(type); + }) + ); + + const { + findByText, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + region: 'fake-region', + stackscript_data: { + cluster_size: 5, + }, + type: type.id, + }, + }, + }); + + await findByText(`5 Nodes - $10/month $2.50/hr`); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.tsx similarity index 93% rename from packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx rename to packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.tsx index 0ce13e3c03b..73277af3bc7 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/Summary.tsx @@ -13,7 +13,8 @@ import { useTypeQuery } from 'src/queries/types'; import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; import { getMonthlyBackupsPrice } from 'src/utilities/pricing/backups'; import { renderMonthlyPriceToCorrectDecimalPlace } from 'src/utilities/pricing/dynamicPricing'; -import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; + +import { getLinodePrice } from './utilities'; import type { CreateLinodeRequest } from '@linode/api-v4'; @@ -35,6 +36,7 @@ export const Summary = () => { vlanLabel, vpcId, diskEncryption, + clusterSize, ] = useWatch({ control, name: [ @@ -49,6 +51,7 @@ export const Summary = () => { 'interfaces.1.label', 'interfaces.0.vpc_id', 'disk_encryption', + 'stackscript_data.cluster_size', ], }); @@ -58,13 +61,12 @@ export const Summary = () => { const region = regions?.find((r) => r.id === regionId); - // @todo handle marketplace cluster pricing (support many nodes by looking at UDF data) - const price = getLinodeRegionPrice(type, regionId); - const backupsPrice = renderMonthlyPriceToCorrectDecimalPlace( getMonthlyBackupsPrice({ region: regionId, type }) ); + const price = getLinodePrice({ type, regionId, clusterSize }); + const summaryItems = [ { item: { @@ -80,10 +82,10 @@ export const Summary = () => { }, { item: { - details: `$${price?.monthly}/month`, + details: price, title: type ? formatStorageUnits(type.label) : typeId, }, - show: Boolean(typeId), + show: price, }, { item: { diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.test.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.test.ts new file mode 100644 index 00000000000..1da6463ccd4 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.test.ts @@ -0,0 +1,33 @@ +import { linodeTypeFactory } from 'src/factories'; + +import { getLinodePrice } from './utilities'; + +describe('getLinodePrice', () => { + it('gets a price for a normal Linode', () => { + const type = linodeTypeFactory.build({ + price: { hourly: 0.1, monthly: 5 }, + }); + + const result = getLinodePrice({ + clusterSize: undefined, + regionId: 'fake-region-id', + type, + }); + + expect(result).toBe('$5/month'); + }); + + it('gets a price for a Marketplace Cluster deployment', () => { + const type = linodeTypeFactory.build({ + price: { hourly: 0.2, monthly: 5 }, + }); + + const result = getLinodePrice({ + clusterSize: '3', + regionId: 'fake-region-id', + type, + }); + + expect(result).toBe('3 Nodes - $15/month $0.60/hr'); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.ts new file mode 100644 index 00000000000..9fc3df07966 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Summary/utilities.ts @@ -0,0 +1,42 @@ +import { renderMonthlyPriceToCorrectDecimalPlace } from 'src/utilities/pricing/dynamicPricing'; +import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; + +import type { LinodeType } from '@linode/api-v4'; + +interface LinodePriceOptions { + clusterSize: string | undefined; + regionId: string | undefined; + type: LinodeType | undefined; +} + +export const getLinodePrice = (options: LinodePriceOptions) => { + const { clusterSize, regionId, type } = options; + const price = getLinodeRegionPrice(type, regionId); + + const isCluster = clusterSize !== undefined; + + if ( + regionId === undefined || + price === undefined || + price.monthly === null || + price.hourly === null + ) { + return undefined; + } + + if (isCluster) { + const numberOfNodes = Number(clusterSize); + + const totalMonthlyPrice = renderMonthlyPriceToCorrectDecimalPlace( + price.monthly * numberOfNodes + ); + + const totalHourlyPrice = renderMonthlyPriceToCorrectDecimalPlace( + price.hourly * numberOfNodes + ); + + return `${numberOfNodes} Nodes - $${totalMonthlyPrice}/month $${totalHourlyPrice}/hr`; + } + + return `$${renderMonthlyPriceToCorrectDecimalPlace(price.monthly)}/month`; +}; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index f476eaef074..b59ad02d374 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -17,7 +17,6 @@ import { } from 'src/queries/linodes/linodes'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; -import { Security } from './Security'; import { Actions } from './Actions'; import { Addons } from './Addons/Addons'; import { Details } from './Details/Details'; @@ -26,7 +25,8 @@ import { Firewall } from './Firewall'; import { Plan } from './Plan'; import { Region } from './Region'; import { linodeCreateResolvers } from './resolvers'; -import { Summary } from './Summary'; +import { Security } from './Security'; +import { Summary } from './Summary/Summary'; import { Backups } from './Tabs/Backups/Backups'; import { Clone } from './Tabs/Clone/Clone'; import { Distributions } from './Tabs/Distributions'; @@ -35,7 +35,6 @@ import { Marketplace } from './Tabs/Marketplace/Marketplace'; import { StackScripts } from './Tabs/StackScripts/StackScripts'; import { UserData } from './UserData/UserData'; import { - LinodeCreateFormValues, defaultValues, defaultValuesMap, getLinodeCreatePayload, @@ -46,6 +45,7 @@ import { import { VLAN } from './VLAN'; import { VPC } from './VPC/VPC'; +import type { LinodeCreateFormValues } from './utilities'; import type { SubmitHandler } from 'react-hook-form'; export const LinodeCreatev2 = () => { From a67822809b5596c17ebe14e8f82ec1db3a48101f Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Tue, 2 Jul 2024 15:08:20 -0400 Subject: [PATCH 143/163] test: [M3-8122] - Disable access to internet for Linodes created during Cypress tests (#10633) * Place Linodes created by Cypress tests behind VLAN, increase Clone timeout * Add changeset --- .../pr-10633-tests-1719934397905.md | 5 ++++ .../e2e/core/linodes/clone-linode.spec.ts | 8 ++---- .../e2e/core/linodes/create-linode.spec.ts | 2 +- .../core/linodes/legacy-create-linode.spec.ts | 10 +++++-- .../core/oneClickApps/one-click-apps.spec.ts | 2 +- .../stackscripts/create-stackscripts.spec.ts | 7 +++-- .../smoke-community-stackscrips.spec.ts | 2 +- .../cypress/support/intercepts/linodes.ts | 11 +++++++- .../manager/cypress/support/util/linodes.ts | 28 +++++++++++++------ 9 files changed, 52 insertions(+), 23 deletions(-) create mode 100644 packages/manager/.changeset/pr-10633-tests-1719934397905.md diff --git a/packages/manager/.changeset/pr-10633-tests-1719934397905.md b/packages/manager/.changeset/pr-10633-tests-1719934397905.md new file mode 100644 index 00000000000..4acac81daa3 --- /dev/null +++ b/packages/manager/.changeset/pr-10633-tests-1719934397905.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Improve security of Linodes created during tests ([#10633](https://github.com/linode/manager/pull/10633)) diff --git a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts index 29273aab7f3..cd9a78f2258 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -33,8 +33,8 @@ const getLinodeCloneUrl = (linode: Linode): string => { return `/linodes/create?linodeID=${linode.id}${regionQuery}&type=Clone+Linode${typeQuery}`; }; -/* Timeout after 3 minutes while waiting for clone. */ -const CLONE_TIMEOUT = 180_000; +/* Timeout after 4 minutes while waiting for clone. */ +const CLONE_TIMEOUT = 240_000; authenticate(); describe('clone linode', () => { @@ -47,7 +47,7 @@ describe('clone linode', () => { * - Confirms that Linode can be cloned successfully. */ it('can clone a Linode from Linode details page', () => { - const linodeRegion = chooseRegion(); + const linodeRegion = chooseRegion({ capabilities: ['Vlans'] }); const linodePayload = createLinodeRequestFactory.build({ label: randomLabel(), region: linodeRegion.id, @@ -64,8 +64,6 @@ describe('clone linode', () => { cy.defer(() => createTestLinode(linodePayload, { securityMethod: 'vlan_no_internet' }) ).then((linode: Linode) => { - const linodeRegion = getRegionById(linodePayload.region!); - interceptCloneLinode(linode.id).as('cloneLinode'); cy.visitWithLogin(`/linodes/${linode.id}`); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index 1945263f5e0..be6fad33848 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -69,7 +69,7 @@ describe('Create Linode', () => { */ it(`creates a ${planConfig.planType} Linode`, () => { const linodeRegion = chooseRegion({ - capabilities: ['Linodes', 'Premium Plans'], + capabilities: ['Linodes', 'Premium Plans', 'Vlans'], }); const linodeLabel = randomLabel(); diff --git a/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts index 0f9e5fc2238..cf92b39a21b 100644 --- a/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/legacy-create-linode.spec.ts @@ -12,7 +12,6 @@ import { getVisible, } from 'support/helpers'; import { ui } from 'support/ui'; -import { apiMatcher } from 'support/util/intercepts'; import { randomString, randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { getRegionById } from 'support/util/regions'; @@ -39,6 +38,7 @@ import { import { mockGetVLANs } from 'support/intercepts/vlans'; import { mockGetLinodeConfigs } from 'support/intercepts/configs'; import { + interceptCreateLinode, mockCreateLinode, mockGetLinodeType, mockGetLinodeTypes, @@ -155,10 +155,14 @@ describe('create linode', () => { // intercept request cy.visitWithLogin('/linodes/create'); cy.get('[data-qa-deploy-linode]'); - cy.intercept('POST', apiMatcher('linode/instances')).as('linodeCreated'); + interceptCreateLinode().as('linodeCreated'); cy.get('[data-qa-header="Create"]').should('have.text', 'Create'); ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionLabel(chooseRegion().label).click(); + ui.regionSelect + .findItemByRegionLabel( + chooseRegion({ capabilities: ['Vlans', 'Linodes'] }).label + ) + .click(); fbtClick('Shared CPU'); getClick('[id="g6-nanode-1"]'); getClick('#linode-label').clear().type(linodeLabel); diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts index 88b3caf6623..3a08199557f 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -162,7 +162,7 @@ describe('OneClick Apps (OCA)', () => { const password = randomString(16); const image = 'linode/ubuntu22.04'; const rootPassword = randomString(16); - const region = chooseRegion(); + const region = chooseRegion({ capabilities: ['Vlans'] }); const linodeLabel = randomLabel(); const levelName = 'Get the enderman!'; diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index 9afa75c6ce6..2733a68d940 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -169,7 +169,7 @@ describe('Create stackscripts', () => { const stackscriptImageTag = 'alpine3.19'; const linodeLabel = randomLabel(); - const linodeRegion = chooseRegion(); + const linodeRegion = chooseRegion({ capabilities: ['Vlans'] }); interceptCreateStackScript().as('createStackScript'); interceptGetStackScripts().as('getStackScripts'); @@ -372,7 +372,10 @@ describe('Create stackscripts', () => { .click(); interceptCreateLinode().as('createLinode'); - fillOutLinodeForm(linodeLabel, chooseRegion().label); + fillOutLinodeForm( + linodeLabel, + chooseRegion({ capabilities: ['Vlans'] }).label + ); ui.button .findByTitle('Create Linode') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts index e96645f3116..9bc796d2c6e 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscrips.spec.ts @@ -260,7 +260,7 @@ describe('Community Stackscripts integration tests', () => { const fairPassword = 'Akamai123'; const rootPassword = randomString(16); const image = 'AlmaLinux 9'; - const region = chooseRegion(); + const region = chooseRegion({ capabilities: ['Vlans'] }); const linodeLabel = randomLabel(); interceptGetStackScripts().as('getStackScripts'); diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index 2a4a898068c..8dfa481462a 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -6,16 +6,25 @@ import { makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; import { makeResponse } from 'support/util/response'; +import { linodeVlanNoInternetConfig } from 'support/util/linodes'; import type { Disk, Kernel, Linode, LinodeType, Volume } from '@linode/api-v4'; /** * Intercepts POST request to create a Linode. * + * The outgoing request payload is modified to create a Linode without access + * to the internet. + * * @returns Cypress chainable. */ export const interceptCreateLinode = (): Cypress.Chainable => { - return cy.intercept('POST', apiMatcher('linode/instances')); + return cy.intercept('POST', apiMatcher('linode/instances'), (req) => { + req.body = { + ...req.body, + interfaces: linodeVlanNoInternetConfig, + }; + }); }; /** diff --git a/packages/manager/cypress/support/util/linodes.ts b/packages/manager/cypress/support/util/linodes.ts index 76186a2ccc0..ad6e6b538d4 100644 --- a/packages/manager/cypress/support/util/linodes.ts +++ b/packages/manager/cypress/support/util/linodes.ts @@ -7,9 +7,26 @@ import { chooseRegion } from 'support/util/regions'; import { depaginate } from './paginate'; import { pageSize } from 'support/constants/api'; -import type { Config, CreateLinodeRequest, Linode } from '@linode/api-v4'; +import type { + Config, + CreateLinodeRequest, + InterfacePayload, + Linode, +} from '@linode/api-v4'; import { findOrCreateDependencyFirewall } from 'support/api/firewalls'; +/** + * Linode create interface to configure a Linode with no public internet access. + */ +export const linodeVlanNoInternetConfig: InterfacePayload[] = [ + { + purpose: 'vlan', + primary: false, + label: randomLabel(), + ipam_address: null, + }, +]; + /** * Methods used to secure test Linodes. * @@ -77,14 +94,7 @@ export const createTestLinode = async ( case 'vlan_no_internet': return { - interfaces: [ - { - purpose: 'vlan', - primary: false, - label: randomLabel(), - ipam_address: null, - }, - ], + interfaces: linodeVlanNoInternetConfig, }; case 'powered_off': From 95ef99f7bb490d151b2f06d4e43ffdb21d7d0b69 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Tue, 2 Jul 2024 16:26:01 -0400 Subject: [PATCH 144/163] fix: [M3-8303] - Remove Paper border in dark theme (#10638) * Allow passing SX on PlanPanel * missed db resize * revert initial changes and remove paper border in dark mode --- packages/manager/src/foundations/themes/dark.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/foundations/themes/dark.ts b/packages/manager/src/foundations/themes/dark.ts index 262d166cd5a..5307ee7aad4 100644 --- a/packages/manager/src/foundations/themes/dark.ts +++ b/packages/manager/src/foundations/themes/dark.ts @@ -599,7 +599,7 @@ export const darkTheme: ThemeOptions = { root: { backgroundColor: Color.Neutrals[90], backgroundImage: 'none', // I have no idea why MUI defaults to setting a background image... - border: `1px solid ${Color.Neutrals[80]}`, + border: 0, }, }, }, From bc14f6d1d167151f3750fdd6d17b5cc2b7614b2e Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 3 Jul 2024 13:43:50 -0400 Subject: [PATCH 145/163] change: [M3-8323] - Remove Region Helper Text on Image Upload (#10642) * remove region helper text * Added changeset: Region helper text on the Image Upload page * remove extra prop --------- Co-authored-by: Banks Nussman --- .../manager/.changeset/pr-10642-removed-1720016794302.md | 5 +++++ .../manager/src/features/Images/ImagesCreate/ImageUpload.tsx | 2 -- 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-10642-removed-1720016794302.md diff --git a/packages/manager/.changeset/pr-10642-removed-1720016794302.md b/packages/manager/.changeset/pr-10642-removed-1720016794302.md new file mode 100644 index 00000000000..405ae306216 --- /dev/null +++ b/packages/manager/.changeset/pr-10642-removed-1720016794302.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Removed +--- + +Region helper text on the Image Upload page ([#10642](https://github.com/linode/manager/pull/10642)) diff --git a/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx index bfef25f2a31..51bf538c31e 100644 --- a/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx +++ b/packages/manager/src/features/Images/ImagesCreate/ImageUpload.tsx @@ -251,14 +251,12 @@ export const ImageUpload = () => { isImageCreateRestricted || form.formState.isSubmitting } textFieldProps={{ - helperTextPosition: 'top', inputRef: field.ref, onBlur: field.onBlur, }} currentCapability={undefined} disableClearable errorText={fieldState.error?.message} - helperText="For fastest initial upload, select the region that is geographically closest to you. Once uploaded, you will be able to deploy the image to other regions." label="Region" onChange={(e, region) => field.onChange(region.id)} regionFilter="core" // Images service will not be supported for Gecko Beta From c7842f06f7fb2c49628b370364b0315b997c8ac7 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 3 Jul 2024 13:55:46 -0400 Subject: [PATCH 146/163] upcoming: [M3-8021] - Manage Image Regions Drawer (#10617) * save progress * save progress * save progress * begin adding unit testing * add more unit testing * update how image is passed via props * add test and adjust regions filter * dial in * more testing * add cypress test * fix spelling * Added changeset: Add Manage Image Regions Drawer * update cypress jsdoc * Added changeset: Update `updateImageRegions` to accept `UpdateImageRegionsPayload` instead of `regions: string[]` * add `Readonly` utility type to `IMAGE_REGION_STATUS_TO_STATUS_ICON_STATUS` * rearchitecture so that `useEffect` is not needed * update unit tests --------- Co-authored-by: Banks Nussman --- .../pr-10617-changed-1719590161430.md | 5 + packages/api-v4/src/images/images.ts | 15 +- packages/api-v4/src/images/types.ts | 9 +- ...r-10617-upcoming-features-1719524941884.md | 5 + .../core/images/manage-image-regions.spec.ts | 213 ++++++++++++++++++ .../cypress/support/intercepts/images.ts | 24 +- .../RegionSelect/RegionMultiSelect.tsx | 2 + .../Images/ImagesLanding/EditImageDrawer.tsx | 19 +- .../ImageRegions/ImageRegionRow.test.tsx | 42 ++++ .../ImageRegions/ImageRegionRow.tsx | 64 ++++++ .../ManageImageRegionsForm.test.tsx | 108 +++++++++ .../ImageRegions/ManageImageRegionsForm.tsx | 150 ++++++++++++ .../Images/ImagesLanding/ImagesLanding.tsx | 187 +++++++-------- .../ImagesLanding/RebuildImageDrawer.tsx | 5 +- packages/manager/src/mocks/serverHandlers.ts | 16 ++ packages/manager/src/queries/images.ts | 17 ++ 16 files changed, 760 insertions(+), 121 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10617-changed-1719590161430.md create mode 100644 packages/manager/.changeset/pr-10617-upcoming-features-1719524941884.md create mode 100644 packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts create mode 100644 packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.test.tsx create mode 100644 packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx create mode 100644 packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx create mode 100644 packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx diff --git a/packages/api-v4/.changeset/pr-10617-changed-1719590161430.md b/packages/api-v4/.changeset/pr-10617-changed-1719590161430.md new file mode 100644 index 00000000000..1f7c25a4e35 --- /dev/null +++ b/packages/api-v4/.changeset/pr-10617-changed-1719590161430.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Changed +--- + +Update `updateImageRegions` to accept `UpdateImageRegionsPayload` instead of `regions: string[]` ([#10617](https://github.com/linode/manager/pull/10617)) diff --git a/packages/api-v4/src/images/images.ts b/packages/api-v4/src/images/images.ts index b012fe396fa..110f19158ed 100644 --- a/packages/api-v4/src/images/images.ts +++ b/packages/api-v4/src/images/images.ts @@ -18,6 +18,7 @@ import type { Image, ImageUploadPayload, UpdateImagePayload, + UpdateImageRegionsPayload, UploadImageResponse, } from './types'; @@ -99,16 +100,14 @@ export const uploadImage = (data: ImageUploadPayload) => { }; /** - * Selects the regions to which this image will be replicated. + * updateImageRegions * - * @param imageId { string } ID of the Image to look up. - * @param regions { string[] } ID of regions to replicate to. Must contain at least one valid region. + * Selects the regions to which this image will be replicated. */ -export const updateImageRegions = (imageId: string, regions: string[]) => { - const data = { - regions, - }; - +export const updateImageRegions = ( + imageId: string, + data: UpdateImageRegionsPayload +) => { return Request( setURL(`${API_ROOT}/images/${encodeURIComponent(imageId)}/regions`), setMethod('POST'), diff --git a/packages/api-v4/src/images/types.ts b/packages/api-v4/src/images/types.ts index e25fb28f9a2..cd3b34db673 100644 --- a/packages/api-v4/src/images/types.ts +++ b/packages/api-v4/src/images/types.ts @@ -8,7 +8,7 @@ export type ImageCapabilities = 'cloud-init' | 'distributed-images'; type ImageType = 'manual' | 'automatic'; -type ImageRegionStatus = +export type ImageRegionStatus = | 'creating' | 'pending' | 'available' @@ -154,3 +154,10 @@ export interface ImageUploadPayload extends BaseImagePayload { label: string; region: string; } + +export interface UpdateImageRegionsPayload { + /** + * An array of region ids + */ + regions: string[]; +} diff --git a/packages/manager/.changeset/pr-10617-upcoming-features-1719524941884.md b/packages/manager/.changeset/pr-10617-upcoming-features-1719524941884.md new file mode 100644 index 00000000000..5047c2d920a --- /dev/null +++ b/packages/manager/.changeset/pr-10617-upcoming-features-1719524941884.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Manage Image Regions Drawer ([#10617](https://github.com/linode/manager/pull/10617)) diff --git a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts new file mode 100644 index 00000000000..663125cd190 --- /dev/null +++ b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts @@ -0,0 +1,213 @@ +import { imageFactory, regionFactory } from 'src/factories'; +import { + mockGetCustomImages, + mockGetRecoveryImages, + mockUpdateImageRegions, +} from 'support/intercepts/images'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import type { Image } from '@linode/api-v4'; + +describe('Manage Image Regions', () => { + /** + * Adds two new regions to an Image (region3 and region4) + * and removes one existing region (region 1). + */ + it("updates an Image's regions", () => { + const region1 = regionFactory.build({ site_type: 'core' }); + const region2 = regionFactory.build({ site_type: 'core' }); + const region3 = regionFactory.build({ site_type: 'core' }); + const region4 = regionFactory.build({ site_type: 'core' }); + + const image = imageFactory.build({ + size: 50, + total_size: 100, + capabilities: ['distributed-images'], + regions: [ + { region: region1.id, status: 'available' }, + { region: region2.id, status: 'available' }, + ], + }); + + mockGetRegions([region1, region2, region3, region4]).as('getRegions'); + mockGetCustomImages([image]).as('getImages'); + mockGetRecoveryImages([]); + + cy.visitWithLogin('/images'); + cy.wait(['@getImages', '@getRegions']); + + cy.findByText(image.label) + .closest('tr') + .within(() => { + // Verify total size is rendered + cy.findByText(`${image.total_size} MB`).should('be.visible'); + + // Verify capabilities are rendered + cy.findByText('Distributed').should('be.visible'); + + // Verify the first region is rendered + cy.findByText(region1.label + ',').should('be.visible'); + + // Click the "+1" + cy.findByText('+1').should('be.visible').should('be.enabled').click(); + }); + + // Verify the Manage Regions drawer opens and contains basic content + ui.drawer + .findByTitle(`Manage Regions for ${image.label}`) + .should('be.visible') + .within(() => { + // Verify the Image regions render + cy.findByText(region1.label).should('be.visible'); + cy.findByText(region2.label).should('be.visible'); + + cy.findByText('Image will be available in these regions (2)').should( + 'be.visible' + ); + + // Verify the "Save" button is disabled because no changes have been made + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.disabled'); + + // Close the Manage Regions drawer + ui.button + .findByTitle('Cancel') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByText(image.label) + .closest('tr') + .within(() => { + // Open the Image's action menu + ui.actionMenu + .findByTitle(`Action menu for Image ${image.label}`) + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Click "Manage Regions" option in the action menu + ui.actionMenuItem + .findByTitle('Manage Regions') + .should('be.visible') + .should('be.enabled') + .click(); + + // Open the Regions Multi-Select + cy.findByLabelText('Add Regions') + .should('be.visible') + .should('be.enabled') + .click(); + + // Verify "Select All" shows up as an option + ui.autocompletePopper + .findByTitle('Select All') + .should('be.visible') + .should('be.enabled'); + + // Verify region3 shows up as an option and select it + ui.autocompletePopper + .findByTitle(`${region3.label} (${region3.id})`) + .should('be.visible') + .should('be.enabled') + .click(); + + // Verify region4 shows up as an option and select it + ui.autocompletePopper + .findByTitle(`${region4.label} (${region4.id})`) + .should('be.visible') + .should('be.enabled') + .click(); + + const updatedImage: Image = { + ...image, + total_size: 150, + regions: [ + { region: region2.id, status: 'available' }, + { region: region3.id, status: 'pending replication' }, + { region: region4.id, status: 'pending replication' }, + ], + }; + + // mock the POST /v4/images/:id:regions response + mockUpdateImageRegions(image.id, updatedImage); + + // mock the updated paginated response + mockGetCustomImages([updatedImage]); + + // Click outside of the Region Multi-Select to commit the selection to the list + ui.drawer + .findByTitle(`Manage Regions for ${image.label}`) + .click() + .within(() => { + // Verify the existing image regions render + cy.findByText(region1.label).should('be.visible'); + cy.findByText(region2.label).should('be.visible'); + + // Verify the newly selected image regions render + cy.findByText(region3.label).should('be.visible'); + cy.findByText(region4.label).should('be.visible'); + cy.findAllByText('unsaved').should('be.visible'); + + // Verify the count is now 3 + cy.findByText('Image will be available in these regions (4)').should( + 'be.visible' + ); + + // Verify the "Save" button is enabled because a new region is selected + ui.button.findByTitle('Save').should('be.visible').should('be.enabled'); + + // Remove region1 + cy.findByLabelText(`Remove ${region1.id}`) + .should('be.visible') + .should('be.enabled') + .click(); + + // Verify the image isn't shown in the list after being removed + cy.findByText(region1.label).should('not.exist'); + + // Verify the count is now 2 + cy.findByText('Image will be available in these regions (3)').should( + 'be.visible' + ); + + // Save changes + ui.button + .findByTitle('Save') + .should('be.visible') + .should('be.enabled') + .click(); + + // "Unsaved" regions should transition to "pending replication" because + // they are now returned by the API + cy.findAllByText('pending replication').should('be.visible'); + + // The save button should become disabled now that changes have been saved + ui.button.findByTitle('Save').should('be.disabled'); + + // The save button should become disabled now that changes have been saved + ui.button.findByTitle('Save').should('be.disabled'); + + cy.findByLabelText('Close drawer').click(); + }); + + ui.toast.assertMessage('Image regions successfully updated.'); + + cy.findByText(image.label) + .closest('tr') + .within(() => { + // Verify the new size is shown + cy.findByText('150 MB'); + + // Verify the first region is rendered + cy.findByText(region2.label + ',').should('be.visible'); + + // Verify the regions count is now "+2" + cy.findByText('+2').should('be.visible').should('be.enabled'); + }); + }); +}); diff --git a/packages/manager/cypress/support/intercepts/images.ts b/packages/manager/cypress/support/intercepts/images.ts index a9a38804c06..9e4a0f7a2bb 100644 --- a/packages/manager/cypress/support/intercepts/images.ts +++ b/packages/manager/cypress/support/intercepts/images.ts @@ -54,9 +54,7 @@ export const mockGetCustomImages = ( const filters = getFilters(req); if (filters?.type === 'manual') { req.reply(paginateResponse(images)); - return; } - req.continue(); }); }; @@ -74,9 +72,7 @@ export const mockGetRecoveryImages = ( const filters = getFilters(req); if (filters?.type === 'automatic') { req.reply(paginateResponse(images)); - return; } - req.continue(); }); }; @@ -130,3 +126,23 @@ export const mockDeleteImage = (id: string): Cypress.Chainable => { const encodedId = encodeURIComponent(id); return cy.intercept('DELETE', apiMatcher(`images/${encodedId}`), {}); }; + +/** + * Intercepts POST request to update an image's regions and mocks the response. + * + * @param id - ID of image + * @param updatedImage - Updated image with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockUpdateImageRegions = ( + id: string, + updatedImage: Image +): Cypress.Chainable => { + const encodedId = encodeURIComponent(id); + return cy.intercept( + 'POST', + apiMatcher(`images/${encodedId}/regions`), + updatedImage + ); +}; diff --git a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx index 4ba6d5879a7..2d3126e008a 100644 --- a/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionMultiSelect.tsx @@ -67,6 +67,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { selectedIds, sortRegionOptions, width, + onClose, } = props; const { @@ -171,6 +172,7 @@ export const RegionMultiSelect = React.memo((props: RegionMultiSelectProps) => { options={regionOptions} placeholder={placeholder ?? 'Select Regions'} value={selectedRegions} + onClose={onClose} /> {selectedRegions.length > 0 && SelectedRegionsList && ( diff --git a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx index 582a7738462..09c2d02e8b2 100644 --- a/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/EditImageDrawer.tsx @@ -8,7 +8,6 @@ import { Drawer } from 'src/components/Drawer'; import { Notice } from 'src/components/Notice/Notice'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; -import { usePrevious } from 'src/hooks/usePrevious'; import { useUpdateImageMutation } from 'src/queries/images'; import { useImageAndLinodeGrantCheck } from '../utils'; @@ -18,18 +17,17 @@ import type { APIError, Image, UpdateImagePayload } from '@linode/api-v4'; interface Props { image: Image | undefined; onClose: () => void; + open: boolean; } export const EditImageDrawer = (props: Props) => { - const { image, onClose } = props; + const { image, onClose, open } = props; const { canCreateImage } = useImageAndLinodeGrantCheck(); - // Prevent content from disappearing when closing drawer - const prevImage = usePrevious(image); const defaultValues = { - description: image?.description ?? prevImage?.description ?? undefined, - label: image?.label ?? prevImage?.label, - tags: image?.tags ?? prevImage?.tags, + description: image?.description ?? undefined, + label: image?.label, + tags: image?.tags, }; const { @@ -78,12 +76,7 @@ export const EditImageDrawer = (props: Props) => { }); return ( - + {!canCreateImage && ( { + it('renders a region label and status', async () => { + const region = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region])); + }) + ); + + const { findByText, getByText } = renderWithTheme( + + ); + + expect(getByText('creating')).toBeVisible(); + expect(await findByText('Newark, NJ')).toBeVisible(); + }); + + it('calls onRemove when the remove button is clicked', async () => { + const onRemove = vi.fn(); + + const { getByLabelText } = renderWithTheme( + + ); + + const removeButton = getByLabelText('Remove us-east'); + + await userEvent.click(removeButton); + + expect(onRemove).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx new file mode 100644 index 00000000000..a3a1ccd292b --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ImageRegionRow.tsx @@ -0,0 +1,64 @@ +import Close from '@mui/icons-material/Close'; +import React from 'react'; + +import { Box } from 'src/components/Box'; +import { Flag } from 'src/components/Flag'; +import { IconButton } from 'src/components/IconButton'; +import { Stack } from 'src/components/Stack'; +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { Typography } from 'src/components/Typography'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import type { ImageRegionStatus } from '@linode/api-v4'; +import type { Status } from 'src/components/StatusIcon/StatusIcon'; + +type ExtendedImageRegionStatus = 'unsaved' | ImageRegionStatus; + +interface Props { + onRemove: () => void; + region: string; + status: ExtendedImageRegionStatus; +} + +export const ImageRegionRow = (props: Props) => { + const { onRemove, region, status } = props; + + const { data: regions } = useRegionsQuery(); + + const actualRegion = regions?.find((r) => r.id === region); + + return ( + + + + {actualRegion?.label ?? region} + + + {status} + + + + + + + ); +}; + +const IMAGE_REGION_STATUS_TO_STATUS_ICON_STATUS: Readonly< + Record +> = { + available: 'active', + creating: 'other', + pending: 'other', + 'pending deletion': 'other', + 'pending replication': 'inactive', + replicating: 'other', + timedout: 'inactive', + unsaved: 'inactive', +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx new file mode 100644 index 00000000000..c3623e4d789 --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.test.tsx @@ -0,0 +1,108 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { imageFactory, regionFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ManageImageRegionsForm } from './ManageImageRegionsForm'; + +describe('ManageImageRegionsDrawer', () => { + it('should render a save button and a cancel button', () => { + const image = imageFactory.build(); + const { getByText } = renderWithTheme( + + ); + + const cancelButton = getByText('Cancel').closest('button'); + const saveButton = getByText('Save').closest('button'); + + expect(cancelButton).toBeVisible(); + expect(cancelButton).toBeEnabled(); + + expect(saveButton).toBeVisible(); + expect(saveButton).toBeDisabled(); // The save button should become enabled when regions are changed + }); + + it('should render existing regions and their statuses', async () => { + const region1 = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' }); + const region2 = regionFactory.build({ id: 'us-west', label: 'Place, CA' }); + + const image = imageFactory.build({ + regions: [ + { + region: 'us-east', + status: 'available', + }, + { + region: 'us-west', + status: 'pending replication', + }, + ], + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region1, region2])); + }) + ); + + const { findByText } = renderWithTheme( + + ); + + await findByText('Newark, NJ'); + await findByText('available'); + await findByText('Place, CA'); + await findByText('pending replication'); + }); + + it('should render a status of "unsaved" when a new region is selected', async () => { + const region1 = regionFactory.build({ id: 'us-east', label: 'Newark, NJ' }); + const region2 = regionFactory.build({ id: 'us-west', label: 'Place, CA' }); + + const image = imageFactory.build({ + regions: [ + { + region: 'us-east', + status: 'available', + }, + ], + }); + + server.use( + http.get('*/v4/regions', () => { + return HttpResponse.json(makeResourcePage([region1, region2])); + }) + ); + + const { findByText, getByLabelText, getByText } = renderWithTheme( + + ); + + const saveButton = getByText('Save').closest('button'); + + expect(saveButton).toBeVisible(); + + // Verify the save button is disabled because no changes have been made + expect(saveButton).toBeDisabled(); + + const regionSelect = getByLabelText('Add Regions'); + + // Open the Region Select + await userEvent.click(regionSelect); + + // Select new region + await userEvent.click(await findByText('us-west', { exact: false })); + + // Close the Region Multi-Select to that selections are committed to the list + await userEvent.type(regionSelect, '{escape}'); + + expect(getByText('Place, CA')).toBeVisible(); + expect(getByText('unsaved')).toBeVisible(); + + // Verify the save button is enabled because changes have been made + expect(saveButton).toBeEnabled(); + }); +}); diff --git a/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx new file mode 100644 index 00000000000..f50c82a36aa --- /dev/null +++ b/packages/manager/src/features/Images/ImagesLanding/ImageRegions/ManageImageRegionsForm.tsx @@ -0,0 +1,150 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { updateImageRegionsSchema } from '@linode/validation'; +import { useSnackbar } from 'notistack'; +import React, { useState } from 'react'; +import { useForm } from 'react-hook-form'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; +import { Paper } from 'src/components/Paper'; +import { RegionMultiSelect } from 'src/components/RegionSelect/RegionMultiSelect'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { useUpdateImageRegionsMutation } from 'src/queries/images'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import { ImageRegionRow } from './ImageRegionRow'; + +import type { Image, UpdateImageRegionsPayload } from '@linode/api-v4'; + +interface Props { + image: Image | undefined; + onClose: () => void; +} + +export const ManageImageRegionsForm = (props: Props) => { + const { image, onClose } = props; + + const imageRegionIds = image?.regions.map(({ region }) => region) ?? []; + + const { enqueueSnackbar } = useSnackbar(); + const { data: regions } = useRegionsQuery(); + const { mutateAsync } = useUpdateImageRegionsMutation(image?.id ?? ''); + + const [selectedRegions, setSelectedRegions] = useState([]); + + const { + formState: { errors, isDirty, isSubmitting }, + handleSubmit, + setError, + setValue, + watch, + } = useForm({ + defaultValues: { regions: imageRegionIds }, + resolver: yupResolver(updateImageRegionsSchema), + values: { regions: imageRegionIds }, + }); + + const onSubmit = async (data: UpdateImageRegionsPayload) => { + try { + await mutateAsync(data); + + enqueueSnackbar('Image regions successfully updated.', { + variant: 'success', + }); + } catch (errors) { + for (const error of errors) { + if (error.field) { + setError(error.field, { message: error.reason }); + } else { + setError('root', { message: error.reason }); + } + } + } + }; + + const values = watch(); + + return ( +
    + {errors.root?.message && ( + + )} + + Custom images are billed monthly, at $.10/GB. Check out{' '} + + this guide + {' '} + for details on managing your Linux system's disk space. + + { + setValue('regions', [...values.regions, ...selectedRegions], { + shouldDirty: true, + shouldValidate: true, + }); + setSelectedRegions([]); + }} + regions={(regions ?? []).filter( + (r) => !values.regions.includes(r.id) && r.site_type === 'core' + )} + currentCapability={undefined} + errorText={errors.regions?.message} + label="Add Regions" + onChange={setSelectedRegions} + placeholder="Select Regions" + selectedIds={selectedRegions} + /> + + Image will be available in these regions ({values.regions.length}) + + ({ + backgroundColor: theme.palette.background.paper, + p: 2, + py: 1, + })} + variant="outlined" + > + + {values.regions.length === 0 && ( + + No Regions Selected + + )} + {values.regions.map((regionId) => ( + + setValue( + 'regions', + values.regions.filter((r) => r !== regionId), + { shouldDirty: true, shouldValidate: true } + ) + } + status={ + image?.regions.find( + (regionItem) => regionItem.region === regionId + )?.status ?? 'unsaved' + } + key={regionId} + region={regionId} + /> + ))} + + + + + ); +}; diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 039a71711c7..28d72c8abb5 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -10,6 +10,7 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { CircleProgress } from 'src/components/CircleProgress'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { Drawer } from 'src/components/Drawer'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Hidden } from 'src/components/Hidden'; import { IconButton } from 'src/components/IconButton'; @@ -24,7 +25,6 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; -import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { TableSortCell } from 'src/components/TableSortCell'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; @@ -45,13 +45,13 @@ import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { getEventsForImages } from '../utils'; import { EditImageDrawer } from './EditImageDrawer'; +import { ManageImageRegionsForm } from './ImageRegions/ManageImageRegionsForm'; import { ImageRow } from './ImageRow'; import { ImagesLandingEmptyState } from './ImagesLandingEmptyState'; import { RebuildImageDrawer } from './RebuildImageDrawer'; import type { Handlers as ImageHandlers } from './ImagesActionMenu'; -import type { Image, ImageStatus } from '@linode/api-v4'; -import type { APIError } from '@linode/api-v4/lib/types'; +import type { ImageStatus } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; const searchQueryKey = 'query'; @@ -212,13 +212,18 @@ export const ImagesLanding = () => { imageEvents ); + const [selectedImageId, setSelectedImageId] = React.useState(); + const [ - // @ts-expect-error This will be unused until the regions drawer is implemented - manageRegionsDrawerImage, - setManageRegionsDrawerImage, - ] = React.useState(); - const [editDrawerImage, setEditDrawerImage] = React.useState(); - const [rebuildDrawerImage, setRebuildDrawerImage] = React.useState(); + isManageRegionsDrawerOpen, + setIsManageRegionsDrawerOpen, + ] = React.useState(false); + const [isEditDrawerOpen, setIsEditDrawerOpen] = React.useState(false); + const [isRebuildDrawerOpen, setIsRebuildDrawerOpen] = React.useState(false); + + const selectedImage = + manualImages?.data.find((i) => i.id === selectedImageId) ?? + automaticImages?.data.find((i) => i.id === selectedImageId); const [dialog, setDialogState] = React.useState( defaultDialogState @@ -312,24 +317,6 @@ export const ImagesLanding = () => { }); }; - const getActions = () => { - return ( - - ); - }; - const resetSearch = () => { queryParams.delete(searchQueryKey); history.push({ search: queryParams.toString() }); @@ -345,61 +332,44 @@ export const ImagesLanding = () => { onCancelFailed: onCancelFailedClick, onDelete: openDialog, onDeploy: deployNewLinode, - onEdit: setEditDrawerImage, + onEdit: (image) => { + setSelectedImageId(image.id); + setIsEditDrawerOpen(true); + }, onManageRegions: multiRegionsEnabled - ? setManageRegionsDrawerImage + ? (image) => { + setSelectedImageId(image.id); + setIsManageRegionsDrawerOpen(true); + } : undefined, - onRestore: setRebuildDrawerImage, + onRestore: (image) => { + setSelectedImageId(image.id); + setIsRebuildDrawerOpen(true); + }, onRetry: onRetryClick, }; - const renderError = (_: APIError[]) => { + if (manualImagesLoading || automaticImagesLoading) { + return ; + } + + if (manualImagesError || automaticImagesError) { return ( ); - }; - - const renderLoading = () => { - return ; - }; - - const renderEmpty = () => { - return ; - }; - - if (manualImagesLoading || automaticImagesLoading) { - return renderLoading(); - } - - /** Error State */ - if (manualImagesError) { - return renderError(manualImagesError); - } - - if (automaticImagesError) { - return renderError(automaticImagesError); } - /** Empty States */ if ( - !manualImages.data.length && - !automaticImages.data.length && + manualImages.results === 0 && + automaticImages.results === 0 && !imageLabelFromParam ) { - return renderEmpty(); + return ; } - const noManualImages = ( - - ); - - const noAutomaticImages = ( - - ); - const isFetching = manualImagesIsFetching || automaticImagesIsFetching; return ( @@ -501,17 +471,21 @@ export const ImagesLanding = () => { - {manualImages.data.length > 0 - ? manualImages.data.map((manualImage) => ( - - )) - : noManualImages} + {manualImages.results === 0 && ( + + )} + {manualImages.data.map((manualImage) => ( + + ))}
    { - {isFetching ? ( - - ) : automaticImages.data.length > 0 ? ( - automaticImages.data.map((automaticImage) => ( - - )) - ) : ( - noAutomaticImages + {automaticImages.results === 0 && ( + )} + {automaticImages.data.map((automaticImage) => ( + + ))} { /> setEditDrawerImage(undefined)} + image={selectedImage} + onClose={() => setIsEditDrawerOpen(false)} + open={isEditDrawerOpen} /> setRebuildDrawerImage(undefined)} + image={selectedImage} + onClose={() => setIsRebuildDrawerOpen(false)} + open={isRebuildDrawerOpen} /> + setIsManageRegionsDrawerOpen(false)} + open={isManageRegionsDrawerOpen} + title={`Manage Regions for ${selectedImage?.label}`} + > + setIsManageRegionsDrawerOpen(false)} + /> + + } title={ dialogAction === 'cancel' ? 'Cancel Upload' : `Delete Image ${dialog.image}` } - actions={getActions} onClose={closeDialog} open={dialog.open} > diff --git a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx index c5d49a70a8a..dc2bf134a93 100644 --- a/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/RebuildImageDrawer.tsx @@ -18,10 +18,11 @@ import type { Image } from '@linode/api-v4'; interface Props { image: Image | undefined; onClose: () => void; + open: boolean; } export const RebuildImageDrawer = (props: Props) => { - const { image, onClose } = props; + const { image, onClose, open } = props; const history = useHistory(); const { @@ -54,7 +55,7 @@ export const RebuildImageDrawer = (props: Props) => { diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 19113ef57f0..198d63e9244 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -107,6 +107,7 @@ import type { ObjectStorageKeyRequest, SecurityQuestionsPayload, TokenRequest, + UpdateImageRegionsPayload, User, VolumeStatus, } from '@linode/api-v4'; @@ -697,6 +698,21 @@ export const handlers = [ return HttpResponse.json(makeResourcePage(images)); }), + http.post( + '*/v4/images/:id/regions', + async ({ request }) => { + const data = await request.json(); + + const image = imageFactory.build(); + + image.regions = data.regions.map((regionId) => ({ + region: regionId, + status: 'pending replication', + })); + + return HttpResponse.json(image); + } + ), http.get('*/linode/types', () => { return HttpResponse.json( diff --git a/packages/manager/src/queries/images.ts b/packages/manager/src/queries/images.ts index 93d2717850b..66cc2eab3d4 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/manager/src/queries/images.ts @@ -2,12 +2,14 @@ import { CreateImagePayload, Image, ImageUploadPayload, + UpdateImageRegionsPayload, UploadImageResponse, createImage, deleteImage, getImage, getImages, updateImage, + updateImageRegions, uploadImage, } from '@linode/api-v4'; import { @@ -134,6 +136,21 @@ export const useUploadImageMutation = () => { }); }; +export const useUpdateImageRegionsMutation = (imageId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data) => updateImageRegions(imageId, data), + onSuccess(image) { + queryClient.invalidateQueries(imageQueries.paginated._def); + queryClient.invalidateQueries(imageQueries.all._def); + queryClient.setQueryData( + imageQueries.image(image.id).queryKey, + image + ); + }, + }); +}; + export const imageEventsHandler = ({ event, queryClient, From 4a452db307c278acb98e4031ffa0add67e3bd524 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Wed, 3 Jul 2024 14:39:41 -0400 Subject: [PATCH 147/163] change: [M3-8322] - Add Design Update Global Notification Banner (#10640) * initial commit: save progress * update with dynamic flag data * Added changeset: Design update dismissible banner --- .../pr-10640-added-1720026539138.md | 5 +++ packages/manager/src/featureFlags.ts | 7 ++++ .../GlobalNotifications.tsx | 3 ++ .../TokensUpdateBanner.tsx | 39 +++++++++++++++++++ 4 files changed, 54 insertions(+) create mode 100644 packages/manager/.changeset/pr-10640-added-1720026539138.md create mode 100644 packages/manager/src/features/GlobalNotifications/TokensUpdateBanner.tsx diff --git a/packages/manager/.changeset/pr-10640-added-1720026539138.md b/packages/manager/.changeset/pr-10640-added-1720026539138.md new file mode 100644 index 00000000000..c52317f7c4b --- /dev/null +++ b/packages/manager/.changeset/pr-10640-added-1720026539138.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Design update dismissible banner ([#10640](https://github.com/linode/manager/pull/10640)) diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index ff4ae7b05d1..632dcedfac4 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -66,13 +66,20 @@ interface AclpFlag { interface gpuV2 { planDivider: boolean; } + type OneClickApp = Record; +interface DesignUpdatesBannerFlag extends BaseFeatureFlag { + key: string; + link: string; +} + export interface Flags { aclb: boolean; aclbFullCreateFlow: boolean; aclp: AclpFlag; apiMaintenance: APIMaintenance; + cloudManagerDesignUpdatesBanner: DesignUpdatesBannerFlag; databaseBeta: boolean; databaseResize: boolean; databases: boolean; diff --git a/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx b/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx index 756ad8b912e..2d600740b1b 100644 --- a/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx +++ b/packages/manager/src/features/GlobalNotifications/GlobalNotifications.tsx @@ -17,7 +17,9 @@ import { ComplianceUpdateModal } from './ComplianceUpdateModal'; import { EmailBounceNotificationSection } from './EmailBounce'; import { RegionStatusBanner } from './RegionStatusBanner'; import { TaxCollectionBanner } from './TaxCollectionBanner'; +import { DesignUpdateBanner } from './TokensUpdateBanner'; import { VerificationDetailsBanner } from './VerificationDetailsBanner'; + export const GlobalNotifications = () => { const flags = useFlags(); const { data: profile } = useProfile(); @@ -51,6 +53,7 @@ export const GlobalNotifications = () => { return ( <> + diff --git a/packages/manager/src/features/GlobalNotifications/TokensUpdateBanner.tsx b/packages/manager/src/features/GlobalNotifications/TokensUpdateBanner.tsx new file mode 100644 index 00000000000..73efd621c61 --- /dev/null +++ b/packages/manager/src/features/GlobalNotifications/TokensUpdateBanner.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; + +import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; +import { Link } from 'src/components/Link'; +import { Typography } from 'src/components/Typography'; +import { useFlags } from 'src/hooks/useFlags'; + +export const DesignUpdateBanner = () => { + const flags = useFlags(); + const designUpdateFlag = flags.cloudManagerDesignUpdatesBanner; + + if (!designUpdateFlag || !designUpdateFlag.enabled) { + return null; + } + const { key, link } = designUpdateFlag; + + /** + * This banner is a reusable banner for future Cloud Manager design updates. + * Since this banner is dismissible, we want to be able to dynamically change the key, + * so we can show it again as needed to users who have dismissed it in the past in the case of a new series of UI updates. + * + * Flag shape is as follows: + * + * { + * "enabled": boolean, + * "key": "some-key", + * "link": "link to docs" + * } + * + */ + return ( + + + We are improving the Cloud Manager experience for our users.{' '} + Read more about recent updates. + + + ); +}; From f707c4dd5b17375f65a8db2c4ea4cedbe1d8c645 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 3 Jul 2024 15:30:56 -0400 Subject: [PATCH 148/163] upcoming: [M3-8320] - Add Image distributed compatibility notice to Linode Create (#10636) * add notice to both create flows with unit tests * fix import * fix error prop * Added changeset: Add Image distributed compatibility notice to Linode Create * Apply suggestions from code review Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * update tooltip to say `distributed compute regions` insted of `distributed regions` --------- Co-authored-by: Banks Nussman Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- ...r-10636-upcoming-features-1719935270528.md | 5 ++ .../components/ImageSelect/ImageOption.tsx | 2 +- .../ImageSelect/ImageSelect.test.tsx | 27 ++++++- .../components/ImageSelect/ImageSelect.tsx | 70 ++++++++++++------- .../ImageSelectv2/ImageOptionv2.test.tsx | 4 +- .../ImageSelectv2/ImageOptionv2.tsx | 2 +- .../LinodeCreatev2/Tabs/Images.test.tsx | 26 +++++++ .../Linodes/LinodeCreatev2/Tabs/Images.tsx | 43 +++++++++--- 8 files changed, 142 insertions(+), 37 deletions(-) create mode 100644 packages/manager/.changeset/pr-10636-upcoming-features-1719935270528.md diff --git a/packages/manager/.changeset/pr-10636-upcoming-features-1719935270528.md b/packages/manager/.changeset/pr-10636-upcoming-features-1719935270528.md new file mode 100644 index 00000000000..a8f6817c173 --- /dev/null +++ b/packages/manager/.changeset/pr-10636-upcoming-features-1719935270528.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Image distributed compatibility notice to Linode Create ([#10636](https://github.com/linode/manager/pull/10636)) diff --git a/packages/manager/src/components/ImageSelect/ImageOption.tsx b/packages/manager/src/components/ImageSelect/ImageOption.tsx index b1da36086ea..c619d845fa2 100644 --- a/packages/manager/src/components/ImageSelect/ImageOption.tsx +++ b/packages/manager/src/components/ImageSelect/ImageOption.tsx @@ -74,7 +74,7 @@ export const ImageOption = (props: ImageOptionProps) => { {data.isDistributedCompatible && ( - +
    diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx index 65eb904c708..76b63511e1d 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.test.tsx @@ -1,8 +1,10 @@ import { DateTime } from 'luxon'; +import React from 'react'; import { imageFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; -import { imagesToGroupedItems } from './ImageSelect'; +import { ImageSelect, imagesToGroupedItems } from './ImageSelect'; describe('imagesToGroupedItems', () => { it('should filter deprecated images when end of life is past beyond 6 months ', () => { @@ -94,3 +96,26 @@ describe('imagesToGroupedItems', () => { expect(imagesToGroupedItems(images)).toStrictEqual(expected); }); }); + +describe('ImageSelect', () => { + it('renders a "Indicates compatibility with distributed compute regions." notice if the user has at least one image with the distributed capability', async () => { + const images = [ + imageFactory.build({ capabilities: [] }), + imageFactory.build({ capabilities: ['distributed-images'] }), + imageFactory.build({ capabilities: [] }), + ]; + + const { getByText } = renderWithTheme( + + ); + + expect( + getByText('Indicates compatibility with distributed compute regions.') + ).toBeVisible(); + }); +}); diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.tsx index f2636bc9422..1fdbb9a4498 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.tsx @@ -1,13 +1,11 @@ -import { Image } from '@linode/api-v4/lib/images'; -import Grid from '@mui/material/Unstable_Grid2'; import produce from 'immer'; import { DateTime } from 'luxon'; import { equals, groupBy } from 'ramda'; import * as React from 'react'; -import Select, { GroupType, Item } from 'src/components/EnhancedSelect'; +import DistributedRegionIcon from 'src/assets/icons/entityIcons/distributed-region.svg'; +import Select from 'src/components/EnhancedSelect'; import { _SingleValue } from 'src/components/EnhancedSelect/components/SingleValue'; -import { BaseSelectProps } from 'src/components/EnhancedSelect/Select'; import { ImageOption } from 'src/components/ImageSelect/ImageOption'; import { Paper } from 'src/components/Paper'; import { Typography } from 'src/components/Typography'; @@ -17,7 +15,13 @@ import { arePropsEqual } from 'src/utilities/arePropsEqual'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getSelectedOptionFromGroupedOptions } from 'src/utilities/getSelectedOptionFromGroupedOptions'; +import { Box } from '../Box'; import { distroIcons } from '../DistributionIcon'; +import { Stack } from '../Stack'; + +import type { Image } from '@linode/api-v4/lib/images'; +import type { GroupType, Item } from 'src/components/EnhancedSelect'; +import type { BaseSelectProps } from 'src/components/EnhancedSelect/Select'; export type Variant = 'all' | 'private' | 'public'; @@ -151,12 +155,12 @@ export const ImageSelect = React.memo((props: ImageSelectProps) => { const { classNames, disabled, + error: errorText, handleSelectImage, images, selectedImageID, title, variant, - ...reactSelectProps } = props; // Check for loading status and request errors in React Query @@ -203,31 +207,47 @@ export const ImageSelect = React.memo((props: ImageSelectProps) => { return handleSelectImage(selection.value, selectedImage); }; + const showDistributedCapabilityNotice = + variant === 'private' && + filteredImages.some((image) => + image.capabilities.includes('distributed-images') + ); + return ( {title} - - - + {showDistributedCapabilityNotice && ( + + + + Indicates compatibility with distributed compute regions. + + + )} + ); }, isMemo); diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx index 33923a9f889..67da3a0bbf6 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.test.tsx @@ -44,7 +44,9 @@ describe('ImageOptionv2', () => { ); expect( - getByLabelText('This image is compatible with distributed regions.') + getByLabelText( + 'This image is compatible with distributed compute regions.' + ) ).toBeVisible(); }); }); diff --git a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx index d8ceb098d02..c1d8c139f3f 100644 --- a/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx +++ b/packages/manager/src/components/ImageSelectv2/ImageOptionv2.tsx @@ -40,7 +40,7 @@ export const ImageOptionv2 = ({ image, isSelected, listItemProps }: Props) => {
    {image.capabilities.includes('distributed-images') && ( - +
    diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.test.tsx index 4ba95eac43a..b2565653537 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.test.tsx @@ -3,6 +3,9 @@ import React from 'react'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { Images } from './Images'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { imageFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; describe('Images', () => { it('renders a header', () => { @@ -27,4 +30,27 @@ describe('Images', () => { expect(getByLabelText('Images')).toBeVisible(); expect(getByPlaceholderText('Choose an image')).toBeVisible(); }); + + it('renders a "Indicates compatibility with distributed compute regions." notice if the user has at least one image with the distributed capability', async () => { + server.use( + http.get('*/v4/images', () => { + const images = [ + imageFactory.build({ capabilities: [] }), + imageFactory.build({ capabilities: ['distributed-images'] }), + imageFactory.build({ capabilities: [] }), + ]; + return HttpResponse.json(makeResourcePage(images)); + }) + ); + + const { findByText } = renderWithThemeAndHookFormContext({ + component: , + }); + + expect( + await findByText( + 'Indicates compatibility with distributed compute regions.' + ) + ).toBeVisible(); + }); }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx index 0962cb7549b..17542f0e313 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Tabs/Images.tsx @@ -1,10 +1,15 @@ import React from 'react'; import { useController, useFormContext, useWatch } from 'react-hook-form'; +import DistributedRegionIcon from 'src/assets/icons/entityIcons/distributed-region.svg'; +import { Box } from 'src/components/Box'; import { ImageSelectv2 } from 'src/components/ImageSelectv2/ImageSelectv2'; +import { getAPIFilterForImageSelect } from 'src/components/ImageSelectv2/utilities'; import { Paper } from 'src/components/Paper'; +import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { useAllImagesQuery } from 'src/queries/images'; import { useRegionsQuery } from 'src/queries/regions/regions'; import type { LinodeCreateFormValues } from '../utilities'; @@ -32,6 +37,7 @@ export const Images = () => { // Non-"distributed compatible" Images must only be deployed to core sites. // Clear the region field if the currently selected region is a distributed site and the Image is only core compatible. + // @todo: delete this logic when all Images are "distributed compatible" if ( image && !image.capabilities.includes('distributed-images') && @@ -41,17 +47,38 @@ export const Images = () => { } }; + const { data: images } = useAllImagesQuery( + {}, + getAPIFilterForImageSelect('private') + ); + + // @todo: delete this logic when all Images are "distributed compatible" + const showDistributedCapabilityNotice = images?.some((image) => + image.capabilities.includes('distributed-images') + ); + return ( Choose an Image - + + + {showDistributedCapabilityNotice && ( + + + + Indicates compatibility with distributed compute regions. + + + )} + ); }; From 0e6227b6779adfaf0b59ddb6f1217e6f84db7d84 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Wed, 3 Jul 2024 14:13:32 -0700 Subject: [PATCH 149/163] change: Link two VSCode extensions mentioned in our Coding Standards docs (#10643) --- docs/development-guide/13-coding-standards.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development-guide/13-coding-standards.md b/docs/development-guide/13-coding-standards.md index 76928a24558..159281d7989 100644 --- a/docs/development-guide/13-coding-standards.md +++ b/docs/development-guide/13-coding-standards.md @@ -11,7 +11,7 @@ We use [ESLint](https://eslint.org/) to enforce coding and formatting standards. - **prettier** (code formatting) - **scanjs** (security) -If you are using VSCode it is highly recommended to use the ESlint extension. The Prettier extension is also recommended, as it can be configured to format your code on save. +If you are using VSCode it is **highly** recommended to use the [ESlint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint). The [Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) is also recommended, as it can be configured to format your code on save. ## React From 4801962abffe74defca0808abf558e9d98c25ccb Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Thu, 4 Jul 2024 17:53:08 +0530 Subject: [PATCH 150/163] feat: [M3-7684] - Disable Create Volume button with tooltiptext on Landing Page for restricted users (#10627) * feat: [M3-7684] - Disable Create Volume button with tooltiptext on Landing Page for restricted users * Added changeset: Disabled Create Volume Button on the Landing Page for Restricted Users --- .../.changeset/pr-10627-added-1719905623432.md | 5 +++++ .../manager/src/features/Volumes/VolumesLanding.tsx | 13 +++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 packages/manager/.changeset/pr-10627-added-1719905623432.md diff --git a/packages/manager/.changeset/pr-10627-added-1719905623432.md b/packages/manager/.changeset/pr-10627-added-1719905623432.md new file mode 100644 index 00000000000..ded64e17a37 --- /dev/null +++ b/packages/manager/.changeset/pr-10627-added-1719905623432.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Disabled Create Volume Button on the Landing Page for Restricted Users ([#10627](https://github.com/linode/manager/pull/10627)) diff --git a/packages/manager/src/features/Volumes/VolumesLanding.tsx b/packages/manager/src/features/Volumes/VolumesLanding.tsx index 120dea0dccb..939107840ef 100644 --- a/packages/manager/src/features/Volumes/VolumesLanding.tsx +++ b/packages/manager/src/features/Volumes/VolumesLanding.tsx @@ -18,8 +18,10 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableSortCell } from 'src/components/TableSortCell'; import { TextField } from 'src/components/TextField'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useVolumesQuery } from 'src/queries/volumes/volumes'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -43,6 +45,9 @@ export const VolumesLanding = () => { const history = useHistory(); const location = useLocation<{ volume: Volume | undefined }>(); const pagination = usePagination(1, preferenceKey); + const isRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_volumes', + }); const queryParams = new URLSearchParams(location.search); const volumeLabelFromParam = queryParams.get(searchQueryKey) ?? ''; @@ -161,6 +166,14 @@ export const VolumesLanding = () => { pathname: location.pathname, removeCrumbX: 1, }} + buttonDataAttrs={{ + tooltipText: getRestrictedResourceText({ + action: 'create', + isSingular: false, + resourceType: 'Volumes', + }), + }} + disabledCreateButton={isRestricted} docsLink="https://www.linode.com/docs/platform/block-storage/how-to-use-block-storage-with-your-linode/" entity="Volume" onButtonClick={() => history.push('/volumes/create')} From 7bfb9d5ab90104ab4979bcf16ab44d1ee37e337d Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Thu, 4 Jul 2024 17:54:05 +0530 Subject: [PATCH 151/163] feat: [M3-7684] - Disable Create Volume button with tooltiptext on 'Empty State Landing Page' for Restricted Users (#10630) * feat: [M3-7684] - Disable Create Volume button with tooltiptext on empty state Landing Page for restricted users * Added changeset: Disabled Create Volume Button on the Empty State Landing Page for Restricted Users * Add unit test for disable create btn on empty state landing page for restricted users * Update changeset file --- .../pr-10630-changed-1719904456730.md | 5 +++ .../Volumes/VolumesLandingEmptyState.test.tsx | 39 +++++++++++++++++++ .../Volumes/VolumesLandingEmptyState.tsx | 12 ++++++ 3 files changed, 56 insertions(+) create mode 100644 packages/manager/.changeset/pr-10630-changed-1719904456730.md create mode 100644 packages/manager/src/features/Volumes/VolumesLandingEmptyState.test.tsx diff --git a/packages/manager/.changeset/pr-10630-changed-1719904456730.md b/packages/manager/.changeset/pr-10630-changed-1719904456730.md new file mode 100644 index 00000000000..5e04441c1e4 --- /dev/null +++ b/packages/manager/.changeset/pr-10630-changed-1719904456730.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Disabled Create Volume button on empty state landing page for restricted users ([#10630](https://github.com/linode/manager/pull/10630)) diff --git a/packages/manager/src/features/Volumes/VolumesLandingEmptyState.test.tsx b/packages/manager/src/features/Volumes/VolumesLandingEmptyState.test.tsx new file mode 100644 index 00000000000..67573292ac4 --- /dev/null +++ b/packages/manager/src/features/Volumes/VolumesLandingEmptyState.test.tsx @@ -0,0 +1,39 @@ +import { waitFor } from '@testing-library/react'; +import React from 'react'; + +import { grantsFactory, profileFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { VolumesLandingEmptyState } from './VolumesLandingEmptyState'; + +describe('VolumesLandingEmptyState', () => { + it('disables the create button if the user does not have permission to create volumes', async () => { + server.use( + http.get('*/v4/profile', () => { + const profile = profileFactory.build({ restricted: true }); + return HttpResponse.json(profile); + }), + http.get('*/v4/profile/grants', () => { + const grants = grantsFactory.build({ global: { add_volumes: false } }); + return HttpResponse.json(grants); + }), + http.get('*/v4/volumes', () => { + return HttpResponse.json(makeResourcePage([])); + }) + ); + + const { getByText } = renderWithTheme(); + + await waitFor(() => { + const createVolumeButton = getByText('Create Volume').closest('button'); + + expect(createVolumeButton).toBeDisabled(); + expect(createVolumeButton).toHaveAttribute( + 'data-qa-tooltip', + "You don't have permissions to create Volumes. Please contact your account administrator to request the necessary permissions." + ); + }); + }); +}); diff --git a/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx b/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx index 38d0fa74ab2..560338843e0 100644 --- a/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx +++ b/packages/manager/src/features/Volumes/VolumesLandingEmptyState.tsx @@ -3,6 +3,8 @@ import { useHistory } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { sendEvent } from 'src/utilities/analytics/utils'; import { StyledVolumeIcon } from './VolumesLandingEmptyState.styles'; @@ -16,6 +18,10 @@ import { export const VolumesLandingEmptyState = () => { const { push } = useHistory(); + const isRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_volumes', + }); + return ( <> @@ -23,6 +29,7 @@ export const VolumesLandingEmptyState = () => { buttonProps={[ { children: 'Create Volume', + disabled: isRestricted, onClick: () => { sendEvent({ action: 'Click:button', @@ -31,6 +38,11 @@ export const VolumesLandingEmptyState = () => { }); push('/volumes/create'); }, + tooltipText: getRestrictedResourceText({ + action: 'create', + isSingular: false, + resourceType: 'Volumes', + }), }, ]} gettingStartedGuidesData={gettingStartedGuides} From e9b23fd545e8d5c6e1fccbcee6a5d2bb7e5ed6fd Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Thu, 4 Jul 2024 17:57:38 +0530 Subject: [PATCH 152/163] change: [M3-7684] - Keep Create Volumes Page Error Notification Position Consistent (#10632) * change: [M3-7684] - Keep Volumes Create Page Error Notification Position Consistent * Added changeset: Position of error notif has been moved from inside the box to outside & used getRestrictedResourceText utility for the Notice * Keep general error inside the paper * Debugging fix * Update changeset file --- .../pr-10632-changed-1719914806428.md | 5 +++++ .../src/features/Volumes/VolumeCreate.tsx | 22 ++++++++++--------- 2 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 packages/manager/.changeset/pr-10632-changed-1719914806428.md diff --git a/packages/manager/.changeset/pr-10632-changed-1719914806428.md b/packages/manager/.changeset/pr-10632-changed-1719914806428.md new file mode 100644 index 00000000000..855b27f535a --- /dev/null +++ b/packages/manager/.changeset/pr-10632-changed-1719914806428.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Use `getRestrictedResourceText` utility and move restrictions Notice to top of Volume Create ([#10632](https://github.com/linode/manager/pull/10632)) diff --git a/packages/manager/src/features/Volumes/VolumeCreate.tsx b/packages/manager/src/features/Volumes/VolumeCreate.tsx index fc3f21d74e9..5c60b9daaf8 100644 --- a/packages/manager/src/features/Volumes/VolumeCreate.tsx +++ b/packages/manager/src/features/Volumes/VolumeCreate.tsx @@ -19,6 +19,7 @@ import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; import { MAX_VOLUME_SIZE } from 'src/constants'; import { EUAgreementCheckbox } from 'src/features/Account/Agreements/EUAgreementCheckbox'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; import { reportAgreementSigningError, @@ -254,6 +255,17 @@ export const VolumeCreate = () => { }} title="Create" /> + {doesNotHavePermission && ( + + )}
    @@ -276,16 +288,6 @@ export const VolumeCreate = () => { variant="error" /> )} - {doesNotHavePermission && ( - - )} Date: Mon, 8 Jul 2024 10:13:36 +0530 Subject: [PATCH 153/163] feat: [M3-7684] - Disable Volume Action Menu Buttons for Restricted Users (#10641) * Disable volume action menu buttons for restricted users * Disable inline action buttons for restricted users * Added changeset: Disable Volume Action Menu buttons for restricted users * refactor by adding attach and detach actions to actionType utils --- .../pr-10641-added-1720011097638.md | 5 ++ .../manager/src/features/Account/utils.ts | 2 + .../features/Volumes/VolumesActionMenu.tsx | 60 ++++++++++++++++++- 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-10641-added-1720011097638.md diff --git a/packages/manager/.changeset/pr-10641-added-1720011097638.md b/packages/manager/.changeset/pr-10641-added-1720011097638.md new file mode 100644 index 00000000000..b1399389c83 --- /dev/null +++ b/packages/manager/.changeset/pr-10641-added-1720011097638.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Disable Volume Action Menu buttons for restricted users ([#10641](https://github.com/linode/manager/pull/10641)) diff --git a/packages/manager/src/features/Account/utils.ts b/packages/manager/src/features/Account/utils.ts index 846cb66a7cc..4fabcaa6edf 100644 --- a/packages/manager/src/features/Account/utils.ts +++ b/packages/manager/src/features/Account/utils.ts @@ -6,9 +6,11 @@ import type { GlobalGrantTypes, GrantLevel } from '@linode/api-v4'; import type { GrantTypeMap } from 'src/features/Account/types'; export type ActionType = + | 'attach' | 'clone' | 'create' | 'delete' + | 'detach' | 'edit' | 'migrate' | 'modify' diff --git a/packages/manager/src/features/Volumes/VolumesActionMenu.tsx b/packages/manager/src/features/Volumes/VolumesActionMenu.tsx index 97fb11c9d9d..8217745d659 100644 --- a/packages/manager/src/features/Volumes/VolumesActionMenu.tsx +++ b/packages/manager/src/features/Volumes/VolumesActionMenu.tsx @@ -6,6 +6,8 @@ import * as React from 'react'; import { Action, ActionMenu } from 'src/components/ActionMenu/ActionMenu'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; +import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; export interface ActionHandlers { handleAttach: () => void; @@ -32,42 +34,94 @@ export const VolumesActionMenu = (props: Props) => { const theme = useTheme(); const matchesSmDown = useMediaQuery(theme.breakpoints.down('md')); + const isVolumeReadOnly = useIsResourceRestricted({ + grantLevel: 'read_only', + grantType: 'volume', + id: volume.id, + }); + const actions: Action[] = [ { onClick: handlers.handleDetails, title: 'Show Config', }, { + disabled: isVolumeReadOnly, onClick: handlers.handleEdit, title: 'Edit', + tooltip: isVolumeReadOnly + ? getRestrictedResourceText({ + action: 'edit', + isSingular: true, + resourceType: 'Volumes', + }) + : undefined, }, { + disabled: isVolumeReadOnly, onClick: handlers.handleResize, title: 'Resize', + tooltip: isVolumeReadOnly + ? getRestrictedResourceText({ + action: 'resize', + isSingular: true, + resourceType: 'Volumes', + }) + : undefined, }, { + disabled: isVolumeReadOnly, onClick: handlers.handleClone, title: 'Clone', + tooltip: isVolumeReadOnly + ? getRestrictedResourceText({ + action: 'clone', + isSingular: true, + resourceType: 'Volumes', + }) + : undefined, }, ]; if (!attached && isVolumesLanding) { actions.push({ + disabled: isVolumeReadOnly, onClick: handlers.handleAttach, title: 'Attach', + tooltip: isVolumeReadOnly + ? getRestrictedResourceText({ + action: 'attach', + isSingular: true, + resourceType: 'Volumes', + }) + : undefined, }); } else { actions.push({ + disabled: isVolumeReadOnly, onClick: handlers.handleDetach, title: 'Detach', + tooltip: isVolumeReadOnly + ? getRestrictedResourceText({ + action: 'detach', + isSingular: true, + resourceType: 'Volumes', + }) + : undefined, }); } actions.push({ - disabled: attached, + disabled: isVolumeReadOnly || attached, onClick: handlers.handleDelete, title: 'Delete', - tooltip: attached + tooltip: isVolumeReadOnly + ? getRestrictedResourceText({ + action: 'delete', + isSingular: true, + resourceType: 'Volumes', + }) + : attached ? 'Your volume must be detached before it can be deleted.' : undefined, }); @@ -82,8 +136,10 @@ export const VolumesActionMenu = (props: Props) => { return ( ); })} From 08aa376eb1a2e446ff68e4c4efe32ee0b75c04a6 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Mon, 8 Jul 2024 10:14:09 +0530 Subject: [PATCH 154/163] fix: [M3-7684] - Incorrect Error Notice in Volume Drawers for Restricted Users (#10646) * Fix error notification notice on volume drawers * Added changeset: Incorrect error notification notice on Volume Drawers * Update changeset file --- packages/manager/.changeset/pr-10646-fixed-1720111189359.md | 5 +++++ packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx | 2 +- packages/manager/src/features/Volumes/ResizeVolumeDrawer.tsx | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-10646-fixed-1720111189359.md diff --git a/packages/manager/.changeset/pr-10646-fixed-1720111189359.md b/packages/manager/.changeset/pr-10646-fixed-1720111189359.md new file mode 100644 index 00000000000..a02eeed2abe --- /dev/null +++ b/packages/manager/.changeset/pr-10646-fixed-1720111189359.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Incorrect error notice in Volume drawers for restricted users ([#10646](https://github.com/linode/manager/pull/10646)) diff --git a/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx b/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx index e19b118990a..0204a22f430 100644 --- a/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/AttachVolumeDrawer.tsx @@ -98,7 +98,7 @@ export const AttachVolumeDrawer = React.memo((props: Props) => { {isReadOnly && ( )} diff --git a/packages/manager/src/features/Volumes/ResizeVolumeDrawer.tsx b/packages/manager/src/features/Volumes/ResizeVolumeDrawer.tsx index 8a9f030f1c0..550e9afa576 100644 --- a/packages/manager/src/features/Volumes/ResizeVolumeDrawer.tsx +++ b/packages/manager/src/features/Volumes/ResizeVolumeDrawer.tsx @@ -98,7 +98,7 @@ export const ResizeVolumeDrawer = (props: Props) => { {isReadOnly && ( )} From 5eec0df2af2bc6df1f491339e31b662ba3bb61fe Mon Sep 17 00:00:00 2001 From: Dajahi Wiley Date: Mon, 8 Jul 2024 10:29:10 -0400 Subject: [PATCH 155/163] Cloud version 1.123.0, API v4 version 0.121.0, and Validation version 0.49.0 --- ...r-10589-upcoming-features-1718971604339.md | 5 -- .../pr-10617-changed-1719590161430.md | 5 -- packages/api-v4/CHANGELOG.md | 10 ++++ packages/api-v4/package.json | 2 +- .../pr-10022-added-1703703204947.md | 5 -- ...r-10479-upcoming-features-1718906242285.md | 5 -- .../pr-10542-tests-1719335118630.md | 5 -- .../pr-10557-tech-stories-1718728411161.md | 5 -- .../pr-10579-tests-1718297804893.md | 5 -- .../pr-10584-fixed-1718376872460.md | 5 -- ...r-10589-upcoming-features-1718716331830.md | 5 -- .../pr-10593-tests-1718736692043.md | 5 -- .../pr-10594-changed-1718748465150.md | 5 -- .../pr-10598-tech-stories-1718897422896.md | 5 -- .../pr-10599-fixed-1718922229335.md | 5 -- .../pr-10602-tests-1718985699307.md | 5 -- .../pr-10604-changed-1719003322690.md | 5 -- ...r-10607-upcoming-features-1719247291579.md | 5 -- .../pr-10609-tests-1719255666019.md | 5 -- ...r-10611-upcoming-features-1719332278758.md | 5 -- .../pr-10612-tests-1719343415784.md | 5 -- ...r-10613-upcoming-features-1719355193187.md | 5 -- .../pr-10614-changed-1719356418613.md | 5 -- .../pr-10615-tests-1719412831217.md | 5 -- ...r-10617-upcoming-features-1719524941884.md | 5 -- .../pr-10618-fixed-1719521792524.md | 5 -- .../pr-10619-tech-stories-1719459021760.md | 5 -- .../pr-10622-tests-1719519067716.md | 5 -- ...r-10623-upcoming-features-1719595144995.md | 5 -- ...r-10628-upcoming-features-1719846490755.md | 5 -- ...r-10629-upcoming-features-1719848495146.md | 5 -- .../pr-10633-tests-1719934397905.md | 5 -- ...r-10636-upcoming-features-1719935270528.md | 5 -- ...r-10637-upcoming-features-1719936723714.md | 5 -- .../pr-10640-added-1720026539138.md | 5 -- .../pr-10642-removed-1720016794302.md | 5 -- packages/manager/CHANGELOG.md | 54 +++++++++++++++++++ packages/manager/package.json | 4 +- .../pr-10557-added-1718728514265.md | 5 -- packages/validation/CHANGELOG.md | 14 +++-- packages/validation/package.json | 2 +- 41 files changed, 74 insertions(+), 187 deletions(-) delete mode 100644 packages/api-v4/.changeset/pr-10589-upcoming-features-1718971604339.md delete mode 100644 packages/api-v4/.changeset/pr-10617-changed-1719590161430.md delete mode 100644 packages/manager/.changeset/pr-10022-added-1703703204947.md delete mode 100644 packages/manager/.changeset/pr-10479-upcoming-features-1718906242285.md delete mode 100644 packages/manager/.changeset/pr-10542-tests-1719335118630.md delete mode 100644 packages/manager/.changeset/pr-10557-tech-stories-1718728411161.md delete mode 100644 packages/manager/.changeset/pr-10579-tests-1718297804893.md delete mode 100644 packages/manager/.changeset/pr-10584-fixed-1718376872460.md delete mode 100644 packages/manager/.changeset/pr-10589-upcoming-features-1718716331830.md delete mode 100644 packages/manager/.changeset/pr-10593-tests-1718736692043.md delete mode 100644 packages/manager/.changeset/pr-10594-changed-1718748465150.md delete mode 100644 packages/manager/.changeset/pr-10598-tech-stories-1718897422896.md delete mode 100644 packages/manager/.changeset/pr-10599-fixed-1718922229335.md delete mode 100644 packages/manager/.changeset/pr-10602-tests-1718985699307.md delete mode 100644 packages/manager/.changeset/pr-10604-changed-1719003322690.md delete mode 100644 packages/manager/.changeset/pr-10607-upcoming-features-1719247291579.md delete mode 100644 packages/manager/.changeset/pr-10609-tests-1719255666019.md delete mode 100644 packages/manager/.changeset/pr-10611-upcoming-features-1719332278758.md delete mode 100644 packages/manager/.changeset/pr-10612-tests-1719343415784.md delete mode 100644 packages/manager/.changeset/pr-10613-upcoming-features-1719355193187.md delete mode 100644 packages/manager/.changeset/pr-10614-changed-1719356418613.md delete mode 100644 packages/manager/.changeset/pr-10615-tests-1719412831217.md delete mode 100644 packages/manager/.changeset/pr-10617-upcoming-features-1719524941884.md delete mode 100644 packages/manager/.changeset/pr-10618-fixed-1719521792524.md delete mode 100644 packages/manager/.changeset/pr-10619-tech-stories-1719459021760.md delete mode 100644 packages/manager/.changeset/pr-10622-tests-1719519067716.md delete mode 100644 packages/manager/.changeset/pr-10623-upcoming-features-1719595144995.md delete mode 100644 packages/manager/.changeset/pr-10628-upcoming-features-1719846490755.md delete mode 100644 packages/manager/.changeset/pr-10629-upcoming-features-1719848495146.md delete mode 100644 packages/manager/.changeset/pr-10633-tests-1719934397905.md delete mode 100644 packages/manager/.changeset/pr-10636-upcoming-features-1719935270528.md delete mode 100644 packages/manager/.changeset/pr-10637-upcoming-features-1719936723714.md delete mode 100644 packages/manager/.changeset/pr-10640-added-1720026539138.md delete mode 100644 packages/manager/.changeset/pr-10642-removed-1720016794302.md delete mode 100644 packages/validation/.changeset/pr-10557-added-1718728514265.md diff --git a/packages/api-v4/.changeset/pr-10589-upcoming-features-1718971604339.md b/packages/api-v4/.changeset/pr-10589-upcoming-features-1718971604339.md deleted file mode 100644 index 31b076dc1d7..00000000000 --- a/packages/api-v4/.changeset/pr-10589-upcoming-features-1718971604339.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Upcoming Features ---- - -Added types needed for DashboardSelect component ([#10589](https://github.com/linode/manager/pull/10589)) diff --git a/packages/api-v4/.changeset/pr-10617-changed-1719590161430.md b/packages/api-v4/.changeset/pr-10617-changed-1719590161430.md deleted file mode 100644 index 1f7c25a4e35..00000000000 --- a/packages/api-v4/.changeset/pr-10617-changed-1719590161430.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/api-v4": Changed ---- - -Update `updateImageRegions` to accept `UpdateImageRegionsPayload` instead of `regions: string[]` ([#10617](https://github.com/linode/manager/pull/10617)) diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index 75ab194a0c6..9af57c6599f 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,13 @@ +## [2024-07-08] - v0.121.0 + +### Changed: + +- Update `updateImageRegions` to accept `UpdateImageRegionsPayload` instead of `regions: string[]` ([#10617](https://github.com/linode/manager/pull/10617)) + +### Upcoming Features: + +- Added types needed for DashboardSelect component ([#10589](https://github.com/linode/manager/pull/10589)) + ## [2024-06-24] - v0.120.0 ### Added: diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 99d23eaffec..792784321bb 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.120.0", + "version": "0.121.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" diff --git a/packages/manager/.changeset/pr-10022-added-1703703204947.md b/packages/manager/.changeset/pr-10022-added-1703703204947.md deleted file mode 100644 index 1a6769e2894..00000000000 --- a/packages/manager/.changeset/pr-10022-added-1703703204947.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Added Design Tokens (CDS 2.0) ([#10022](https://github.com/linode/manager/pull/10022)) diff --git a/packages/manager/.changeset/pr-10479-upcoming-features-1718906242285.md b/packages/manager/.changeset/pr-10479-upcoming-features-1718906242285.md deleted file mode 100644 index 63d0181b024..00000000000 --- a/packages/manager/.changeset/pr-10479-upcoming-features-1718906242285.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Gecko GA Region Select ([#10479](https://github.com/linode/manager/pull/10479)) diff --git a/packages/manager/.changeset/pr-10542-tests-1719335118630.md b/packages/manager/.changeset/pr-10542-tests-1719335118630.md deleted file mode 100644 index b5a04fd34d0..00000000000 --- a/packages/manager/.changeset/pr-10542-tests-1719335118630.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Cypress integration test for SSH key update and delete ([#10542](https://github.com/linode/manager/pull/10542)) diff --git a/packages/manager/.changeset/pr-10557-tech-stories-1718728411161.md b/packages/manager/.changeset/pr-10557-tech-stories-1718728411161.md deleted file mode 100644 index 4dbee382763..00000000000 --- a/packages/manager/.changeset/pr-10557-tech-stories-1718728411161.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Refactor `SupportTicketDialog` with React Hook Form ([#10557](https://github.com/linode/manager/pull/10557)) diff --git a/packages/manager/.changeset/pr-10579-tests-1718297804893.md b/packages/manager/.changeset/pr-10579-tests-1718297804893.md deleted file mode 100644 index ecd89ebaf74..00000000000 --- a/packages/manager/.changeset/pr-10579-tests-1718297804893.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Refactor Cypress Longview test to use mock API data/events ([#10579](https://github.com/linode/manager/pull/10579)) diff --git a/packages/manager/.changeset/pr-10584-fixed-1718376872460.md b/packages/manager/.changeset/pr-10584-fixed-1718376872460.md deleted file mode 100644 index 586c1a96686..00000000000 --- a/packages/manager/.changeset/pr-10584-fixed-1718376872460.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Potential runtime issue with conditional hook ([#10584](https://github.com/linode/manager/pull/10584)) diff --git a/packages/manager/.changeset/pr-10589-upcoming-features-1718716331830.md b/packages/manager/.changeset/pr-10589-upcoming-features-1718716331830.md deleted file mode 100644 index 2d179125d88..00000000000 --- a/packages/manager/.changeset/pr-10589-upcoming-features-1718716331830.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Added Dashboard Selection component inside the Global Filters of CloudPulse view. ([#10589](https://github.com/linode/manager/pull/10589)) diff --git a/packages/manager/.changeset/pr-10593-tests-1718736692043.md b/packages/manager/.changeset/pr-10593-tests-1718736692043.md deleted file mode 100644 index 7fb89a5ac1d..00000000000 --- a/packages/manager/.changeset/pr-10593-tests-1718736692043.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Add assertions for created LKE cluster in Cypress LKE tests ([#10593](https://github.com/linode/manager/pull/10593)) diff --git a/packages/manager/.changeset/pr-10594-changed-1718748465150.md b/packages/manager/.changeset/pr-10594-changed-1718748465150.md deleted file mode 100644 index 4f2629f6b4f..00000000000 --- a/packages/manager/.changeset/pr-10594-changed-1718748465150.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Rebuild Linode drawer ([#10594](https://github.com/linode/manager/pull/10594)) diff --git a/packages/manager/.changeset/pr-10598-tech-stories-1718897422896.md b/packages/manager/.changeset/pr-10598-tech-stories-1718897422896.md deleted file mode 100644 index 9dfffac3dd4..00000000000 --- a/packages/manager/.changeset/pr-10598-tech-stories-1718897422896.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Query Key Factory for ACLB ([#10598](https://github.com/linode/manager/pull/10598)) diff --git a/packages/manager/.changeset/pr-10599-fixed-1718922229335.md b/packages/manager/.changeset/pr-10599-fixed-1718922229335.md deleted file mode 100644 index fc9d4f21be1..00000000000 --- a/packages/manager/.changeset/pr-10599-fixed-1718922229335.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Visual bug inside Node Pools Table ([#10599](https://github.com/linode/manager/pull/10599)) diff --git a/packages/manager/.changeset/pr-10602-tests-1718985699307.md b/packages/manager/.changeset/pr-10602-tests-1718985699307.md deleted file mode 100644 index 82ccb0de864..00000000000 --- a/packages/manager/.changeset/pr-10602-tests-1718985699307.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Update Object Storage tests to mock account capabilities as needed for multi cluster ([#10602](https://github.com/linode/manager/pull/10602)) diff --git a/packages/manager/.changeset/pr-10604-changed-1719003322690.md b/packages/manager/.changeset/pr-10604-changed-1719003322690.md deleted file mode 100644 index 58c69476395..00000000000 --- a/packages/manager/.changeset/pr-10604-changed-1719003322690.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Auto-populate Image label based on Linode and Disk names ([#10604](https://github.com/linode/manager/pull/10604)) diff --git a/packages/manager/.changeset/pr-10607-upcoming-features-1719247291579.md b/packages/manager/.changeset/pr-10607-upcoming-features-1719247291579.md deleted file mode 100644 index 695c55b6a9a..00000000000 --- a/packages/manager/.changeset/pr-10607-upcoming-features-1719247291579.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Conditionally disable regions based on the selected image on Linode Create ([#10607](https://github.com/linode/manager/pull/10607)) diff --git a/packages/manager/.changeset/pr-10609-tests-1719255666019.md b/packages/manager/.changeset/pr-10609-tests-1719255666019.md deleted file mode 100644 index 36fd2352e8a..00000000000 --- a/packages/manager/.changeset/pr-10609-tests-1719255666019.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Fix OBJ test failure caused by visiting hardcoded and out-of-date URL ([#10609](https://github.com/linode/manager/pull/10609)) diff --git a/packages/manager/.changeset/pr-10611-upcoming-features-1719332278758.md b/packages/manager/.changeset/pr-10611-upcoming-features-1719332278758.md deleted file mode 100644 index c3bc81d8170..00000000000 --- a/packages/manager/.changeset/pr-10611-upcoming-features-1719332278758.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Prevent Linode Create v2 from toggling mid-creation ([#10611](https://github.com/linode/manager/pull/10611)) diff --git a/packages/manager/.changeset/pr-10612-tests-1719343415784.md b/packages/manager/.changeset/pr-10612-tests-1719343415784.md deleted file mode 100644 index 8c3b73cf285..00000000000 --- a/packages/manager/.changeset/pr-10612-tests-1719343415784.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Combine VPC details page subnet create, edit, and delete Cypress tests ([#10612](https://github.com/linode/manager/pull/10612)) diff --git a/packages/manager/.changeset/pr-10613-upcoming-features-1719355193187.md b/packages/manager/.changeset/pr-10613-upcoming-features-1719355193187.md deleted file mode 100644 index 410b25c641b..00000000000 --- a/packages/manager/.changeset/pr-10613-upcoming-features-1719355193187.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add new search query parser to Linode Create v2 StackScripts tab ([#10613](https://github.com/linode/manager/pull/10613)) diff --git a/packages/manager/.changeset/pr-10614-changed-1719356418613.md b/packages/manager/.changeset/pr-10614-changed-1719356418613.md deleted file mode 100644 index 96847464667..00000000000 --- a/packages/manager/.changeset/pr-10614-changed-1719356418613.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Changed ---- - -Update Linode disk action menu ([#10614](https://github.com/linode/manager/pull/10614)) diff --git a/packages/manager/.changeset/pr-10615-tests-1719412831217.md b/packages/manager/.changeset/pr-10615-tests-1719412831217.md deleted file mode 100644 index 0c93492cabc..00000000000 --- a/packages/manager/.changeset/pr-10615-tests-1719412831217.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -De-Parameterize Cypress Domain Record Create Tests ([#10615](https://github.com/linode/manager/pull/10615)) diff --git a/packages/manager/.changeset/pr-10617-upcoming-features-1719524941884.md b/packages/manager/.changeset/pr-10617-upcoming-features-1719524941884.md deleted file mode 100644 index 5047c2d920a..00000000000 --- a/packages/manager/.changeset/pr-10617-upcoming-features-1719524941884.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add Manage Image Regions Drawer ([#10617](https://github.com/linode/manager/pull/10617)) diff --git a/packages/manager/.changeset/pr-10618-fixed-1719521792524.md b/packages/manager/.changeset/pr-10618-fixed-1719521792524.md deleted file mode 100644 index ebceb5f44ad..00000000000 --- a/packages/manager/.changeset/pr-10618-fixed-1719521792524.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Fixed ---- - -Linode Resize dialog UX when linode data is loading or there is an error ([#10618](https://github.com/linode/manager/pull/10618)) diff --git a/packages/manager/.changeset/pr-10619-tech-stories-1719459021760.md b/packages/manager/.changeset/pr-10619-tech-stories-1719459021760.md deleted file mode 100644 index 7bd3fb5ae3d..00000000000 --- a/packages/manager/.changeset/pr-10619-tech-stories-1719459021760.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tech Stories ---- - -Make `Factory.each` start incrementing at 1 instead of 0 ([#10619](https://github.com/linode/manager/pull/10619)) diff --git a/packages/manager/.changeset/pr-10622-tests-1719519067716.md b/packages/manager/.changeset/pr-10622-tests-1719519067716.md deleted file mode 100644 index db48d82236e..00000000000 --- a/packages/manager/.changeset/pr-10622-tests-1719519067716.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -De-Parameterize Cypress Deep Link Smoke Tests ([#10622](https://github.com/linode/manager/pull/10622)) diff --git a/packages/manager/.changeset/pr-10623-upcoming-features-1719595144995.md b/packages/manager/.changeset/pr-10623-upcoming-features-1719595144995.md deleted file mode 100644 index 7ac2be855fa..00000000000 --- a/packages/manager/.changeset/pr-10623-upcoming-features-1719595144995.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add Marketplace Cluster pricing support to Linode Create v2 ([#10623](https://github.com/linode/manager/pull/10623)) diff --git a/packages/manager/.changeset/pr-10628-upcoming-features-1719846490755.md b/packages/manager/.changeset/pr-10628-upcoming-features-1719846490755.md deleted file mode 100644 index 4334c6b6240..00000000000 --- a/packages/manager/.changeset/pr-10628-upcoming-features-1719846490755.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add debouncing to the Linode Create v2 `VLANSelect` ([#10628](https://github.com/linode/manager/pull/10628)) diff --git a/packages/manager/.changeset/pr-10629-upcoming-features-1719848495146.md b/packages/manager/.changeset/pr-10629-upcoming-features-1719848495146.md deleted file mode 100644 index 16ba99d5879..00000000000 --- a/packages/manager/.changeset/pr-10629-upcoming-features-1719848495146.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add Validation to Linode Create v2 Marketplace Tab ([#10629](https://github.com/linode/manager/pull/10629)) diff --git a/packages/manager/.changeset/pr-10633-tests-1719934397905.md b/packages/manager/.changeset/pr-10633-tests-1719934397905.md deleted file mode 100644 index 4acac81daa3..00000000000 --- a/packages/manager/.changeset/pr-10633-tests-1719934397905.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Tests ---- - -Improve security of Linodes created during tests ([#10633](https://github.com/linode/manager/pull/10633)) diff --git a/packages/manager/.changeset/pr-10636-upcoming-features-1719935270528.md b/packages/manager/.changeset/pr-10636-upcoming-features-1719935270528.md deleted file mode 100644 index a8f6817c173..00000000000 --- a/packages/manager/.changeset/pr-10636-upcoming-features-1719935270528.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Add Image distributed compatibility notice to Linode Create ([#10636](https://github.com/linode/manager/pull/10636)) diff --git a/packages/manager/.changeset/pr-10637-upcoming-features-1719936723714.md b/packages/manager/.changeset/pr-10637-upcoming-features-1719936723714.md deleted file mode 100644 index 70820ca8f4c..00000000000 --- a/packages/manager/.changeset/pr-10637-upcoming-features-1719936723714.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Upcoming Features ---- - -Fix Notification Toast in Dark Mode ([#10637](https://github.com/linode/manager/pull/10637)) diff --git a/packages/manager/.changeset/pr-10640-added-1720026539138.md b/packages/manager/.changeset/pr-10640-added-1720026539138.md deleted file mode 100644 index c52317f7c4b..00000000000 --- a/packages/manager/.changeset/pr-10640-added-1720026539138.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Added ---- - -Design update dismissible banner ([#10640](https://github.com/linode/manager/pull/10640)) diff --git a/packages/manager/.changeset/pr-10642-removed-1720016794302.md b/packages/manager/.changeset/pr-10642-removed-1720016794302.md deleted file mode 100644 index 405ae306216..00000000000 --- a/packages/manager/.changeset/pr-10642-removed-1720016794302.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/manager": Removed ---- - -Region helper text on the Image Upload page ([#10642](https://github.com/linode/manager/pull/10642)) diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index 220f3ccf276..1edbc296bda 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,6 +4,60 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2024-07-08] - v1.123.0 + +### Added: + +- Design Tokens (CDS 2.0) ([#10022](https://github.com/linode/manager/pull/10022)) +- Design update dismissible banner ([#10640](https://github.com/linode/manager/pull/10640)) + +### Changed: + +- Rebuild Linode drawer ([#10594](https://github.com/linode/manager/pull/10594)) +- Auto-populate Image label based on Linode and Disk names ([#10604](https://github.com/linode/manager/pull/10604)) +- Update Linode disk action menu ([#10614](https://github.com/linode/manager/pull/10614)) + +### Fixed: + +- Potential runtime issue with conditional hook ([#10584](https://github.com/linode/manager/pull/10584)) +- Visual bug inside Node Pools table ([#10599](https://github.com/linode/manager/pull/10599)) +- Linode Resize dialog UX when linode data is loading or there is an error ([#10618](https://github.com/linode/manager/pull/10618)) + +### Removed: + +- Region helper text on the Image Upload page ([#10642](https://github.com/linode/manager/pull/10642)) + +### Tech Stories: + +- Refactor `SupportTicketDialog` with React Hook Form ([#10557](https://github.com/linode/manager/pull/10557)) +- Query Key Factory for ACLB ([#10598](https://github.com/linode/manager/pull/10598)) +- Make `Factory.each` start incrementing at 1 instead of 0 ([#10619](https://github.com/linode/manager/pull/10619)) + +### Tests: + +- Cypress integration test for SSH key update and delete ([#10542](https://github.com/linode/manager/pull/10542)) +- Refactor Cypress Longview test to use mock API data/events ([#10579](https://github.com/linode/manager/pull/10579)) +- Add assertions for created LKE cluster in Cypress LKE tests ([#10593](https://github.com/linode/manager/pull/10593)) +- Update Object Storage tests to mock account capabilities as needed for Multicluster ([#10602](https://github.com/linode/manager/pull/10602)) +- Fix OBJ test failure caused by visiting hardcoded and out-of-date URL ([#10609](https://github.com/linode/manager/pull/10609)) +- Combine VPC details page subnet create, edit, and delete Cypress tests ([#10612](https://github.com/linode/manager/pull/10612)) +- De-parameterize Cypress Domain Record Create tests ([#10615](https://github.com/linode/manager/pull/10615)) +- De-parameterize Cypress Deep Link smoke tests ([#10622](https://github.com/linode/manager/pull/10622)) +- Improve security of Linodes created during tests ([#10633](https://github.com/linode/manager/pull/10633)) + +### Upcoming Features: + +- Gecko GA Region Select ([#10479](https://github.com/linode/manager/pull/10479)) +- Add Dashboard Selection component inside the Global Filters of CloudPulse view ([#10589](https://github.com/linode/manager/pull/10589)) +- Conditionally disable regions based on the selected image on Linode Create ([#10607](https://github.com/linode/manager/pull/10607)) +- Prevent Linode Create v2 from toggling mid-creation ([#10611](https://github.com/linode/manager/pull/10611)) +- Add new search query parser to Linode Create v2 StackScripts tab ([#10613](https://github.com/linode/manager/pull/10613)) +- Add ‘Manage Image Regions’ Drawer ([#10617](https://github.com/linode/manager/pull/10617)) +- Add Marketplace Cluster pricing support to Linode Create v2 ([#10623](https://github.com/linode/manager/pull/10623)) +- Add debouncing to the Linode Create v2 `VLANSelect` ([#10628](https://github.com/linode/manager/pull/10628)) +- Add Validation to Linode Create v2 Marketplace Tab ([#10629](https://github.com/linode/manager/pull/10629)) +- Add Image distributed compatibility notice to Linode Create ([#10636](https://github.com/linode/manager/pull/10636)) + ## [2024-06-24] - v1.122.0 ### Added: diff --git a/packages/manager/package.json b/packages/manager/package.json index 2bc53708ac9..9c03007ac00 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.122.0", + "version": "1.123.0", "private": true, "type": "module", "bugs": { @@ -223,4 +223,4 @@ "Firefox ESR", "not ie < 9" ] -} \ No newline at end of file +} diff --git a/packages/validation/.changeset/pr-10557-added-1718728514265.md b/packages/validation/.changeset/pr-10557-added-1718728514265.md deleted file mode 100644 index c4532414d45..00000000000 --- a/packages/validation/.changeset/pr-10557-added-1718728514265.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@linode/validation": Added ---- - -`createSMTPSupportTicketSchema` to support schemas ([#10557](https://github.com/linode/manager/pull/10557)) diff --git a/packages/validation/CHANGELOG.md b/packages/validation/CHANGELOG.md index b0ba34e3bbc..0f7aed6d817 100644 --- a/packages/validation/CHANGELOG.md +++ b/packages/validation/CHANGELOG.md @@ -1,5 +1,10 @@ -## [2024-06-10] - v0.48.0 +## [2024-07-08] - v0.49.0 + +### Added: +- `createSMTPSupportTicketSchema` to support schemas ([#10557](https://github.com/linode/manager/pull/10557)) + +## [2024-06-10] - v0.48.0 ### Added: @@ -8,7 +13,6 @@ ## [2024-05-28] - v0.47.0 - ### Added: - `tags` to `createImageSchema` ([#10471](https://github.com/linode/manager/pull/10471)) @@ -18,19 +22,15 @@ - Adjust DiskEncryptionSchema so it is not an object ([#10462](https://github.com/linode/manager/pull/10462)) - Improve Image `label` validation ([#10471](https://github.com/linode/manager/pull/10471)) - ## [2024-05-13] - v0.46.0 - ### Changed: - Include disk_encryption in CreateLinodeSchema and RebuildLinodeSchema ([#10413](https://github.com/linode/manager/pull/10413)) - Allow `backup_id` to be nullable in `CreateLinodeSchema` ([#10421](https://github.com/linode/manager/pull/10421)) - ## [2024-04-29] - v0.45.0 - ### Changed: - Improved VPC `ip_ranges` validation in `LinodeInterfaceSchema` ([#10354](https://github.com/linode/manager/pull/10354)) @@ -52,12 +52,10 @@ ## [2024-03-18] - v0.42.0 - ### Changed: - Update TCP rules to not include a `match_condition` ([#10264](https://github.com/linode/manager/pull/10264)) - ## [2024-03-04] - v0.41.0 ### Upcoming Features: diff --git a/packages/validation/package.json b/packages/validation/package.json index 06dcd0ca568..34d55b981b4 100644 --- a/packages/validation/package.json +++ b/packages/validation/package.json @@ -1,6 +1,6 @@ { "name": "@linode/validation", - "version": "0.48.0", + "version": "0.49.0", "description": "Yup validation schemas for use with the Linode APIv4", "type": "module", "main": "lib/index.cjs", From 719841bb070c967ba09494e57ab28739782391b2 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Mon, 8 Jul 2024 12:25:11 -0400 Subject: [PATCH 156/163] Release v1.123.0 fixes (#10650) * token updates: Fix chip bg/color * token updates: Fix chip bg/color --- packages/manager/src/foundations/themes/dark.ts | 3 +++ packages/manager/src/foundations/themes/light.ts | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/manager/src/foundations/themes/dark.ts b/packages/manager/src/foundations/themes/dark.ts index 5307ee7aad4..4a374ee9d1b 100644 --- a/packages/manager/src/foundations/themes/dark.ts +++ b/packages/manager/src/foundations/themes/dark.ts @@ -354,6 +354,9 @@ export const darkTheme: ThemeOptions = { color: 'primary', }, styleOverrides: { + clickable: { + color: Color.Brand[100], + }, colorError: { backgroundColor: Badge.Bold.Red.Background, color: Badge.Bold.Red.Text, diff --git a/packages/manager/src/foundations/themes/light.ts b/packages/manager/src/foundations/themes/light.ts index 5b8dfa3338b..548cfb375cb 100644 --- a/packages/manager/src/foundations/themes/light.ts +++ b/packages/manager/src/foundations/themes/light.ts @@ -555,10 +555,10 @@ export const lightTheme: ThemeOptions = { styleOverrides: { clickable: { '&:focus': { - bbackgroundColor: Color.Brand[40], // TODO: This was the closest color according to our palette + backgroundColor: Color.Brand[30], // TODO: This was the closest color according to our palette }, '&:hover': { - bbackgroundColor: Color.Brand[40], // TODO: This was the closest color according to our palette + backgroundColor: Color.Brand[30], // TODO: This was the closest color according to our palette }, backgroundColor: Color.Brand[10], // TODO: This was the closest color according to our palette }, From dadc45112fd7aa8f6273461ade4c9f732032253c Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Mon, 8 Jul 2024 12:30:42 -0400 Subject: [PATCH 157/163] upcoming: [M3-8295] - Add feature flag and capability for OBJ Gen2 (#10647) Co-authored-by: Jaalah Ramos --- packages/api-v4/src/account/types.ts | 3 ++- .../.changeset/pr-10647-upcoming-features-1720210373584.md | 5 +++++ packages/manager/src/dev-tools/FeatureFlagTool.tsx | 1 + packages/manager/src/featureFlags.ts | 1 + 4 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-10647-upcoming-features-1720210373584.md diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index e5d3c615fb3..02e3cbf3ec8 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -61,9 +61,9 @@ export type BillingSource = 'linode' | 'akamai'; export type AccountCapability = | 'Akamai Cloud Load Balancer' - | 'CloudPulse' | 'Block Storage' | 'Cloud Firewall' + | 'CloudPulse' | 'Disk Encryption' | 'Kubernetes' | 'Linodes' @@ -72,6 +72,7 @@ export type AccountCapability = | 'Managed Databases' | 'NodeBalancers' | 'Object Storage Access Key Regions' + | 'Object Storage Endpoint Types' | 'Object Storage' | 'Placement Group' | 'Support Ticket Severity' diff --git a/packages/manager/.changeset/pr-10647-upcoming-features-1720210373584.md b/packages/manager/.changeset/pr-10647-upcoming-features-1720210373584.md new file mode 100644 index 00000000000..edfa6edd243 --- /dev/null +++ b/packages/manager/.changeset/pr-10647-upcoming-features-1720210373584.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add feature flag and capability for OBJ Gen2 ([#10647](https://github.com/linode/manager/pull/10647)) diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 74986c22644..5e3d744f720 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -29,6 +29,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'linodeCreateRefactor', label: 'Linode Create v2' }, { flag: 'linodeDiskEncryption', label: 'Linode Disk Encryption (LDE)' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, + { flag: 'objectStorageGen2', label: 'OBJ Gen2' }, { flag: 'placementGroups', label: 'Placement Groups' }, { flag: 'selfServeBetas', label: 'Self Serve Betas' }, { flag: 'supportTicketSeverity', label: 'Support Ticket Severity' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index 632dcedfac4..fd537b7e0ef 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -96,6 +96,7 @@ export interface Flags { mainContentBanner: MainContentBanner; metadata: boolean; objMultiCluster: boolean; + objectStorageGen2: BaseFeatureFlag; oneClickApps: OneClickApp; oneClickAppsDocsOverride: Record; placementGroups: BetaFeatureFlag; From 0f2e15c02addb9636d75a22ca38b81bc152ba621 Mon Sep 17 00:00:00 2001 From: cliu-akamai <126020611+cliu-akamai@users.noreply.github.com> Date: Mon, 8 Jul 2024 16:49:00 -0400 Subject: [PATCH 158/163] test: [M3-8133] - Add Cypress test for Login History page (#10575) * M3-8133 Add Cypress test for Login History page * fixed comments * Added changeset: Add Cypress test for Login History page --- .../pr-10575-tests-1720464616116.md | 5 + .../account/account-login-history.spec.ts | 122 ++++++++++++++++-- .../cypress/support/constants/account.ts | 11 ++ 3 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 packages/manager/.changeset/pr-10575-tests-1720464616116.md diff --git a/packages/manager/.changeset/pr-10575-tests-1720464616116.md b/packages/manager/.changeset/pr-10575-tests-1720464616116.md new file mode 100644 index 00000000000..774da01e27e --- /dev/null +++ b/packages/manager/.changeset/pr-10575-tests-1720464616116.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add Cypress test for Login History page ([#10575](https://github.com/linode/manager/pull/10575)) diff --git a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts index bd33a939c29..d8abac82d79 100644 --- a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts @@ -7,6 +7,10 @@ import { accountLoginFactory } from 'src/factories/accountLogin'; import { formatDate } from 'src/utilities/formatDate'; import { mockGetAccountLogins } from 'support/intercepts/account'; import { mockGetProfile } from 'support/intercepts/profile'; +import { + loginHelperText, + loginEmptyStateMessageText, +} from 'support/constants/account'; import { PARENT_USER } from 'src/features/Account/constants'; describe('Account login history', () => { @@ -42,9 +46,7 @@ describe('Account login history', () => { cy.wait(['@getProfile']); // Confirm helper text above table is visible. - cy.findByText( - 'Logins across all users on your account over the last 90 days.' - ).should('be.visible'); + cy.findByText(loginHelperText).should('be.visible'); // Confirm the login table includes the expected column headers and mocked logins are visible in table. cy.findByLabelText('Account Logins').within(() => { @@ -72,9 +74,6 @@ describe('Account login history', () => { .closest('tr') .within(() => { // Confirm that successful login and status icon display in table. - cy.findByText(mockSuccessfulLogin.status, { exact: false }).should( - 'be.visible' - ); cy.findAllByLabelText(`Status is ${mockSuccessfulLogin.status}`); // Confirm all other fields display in table. @@ -108,9 +107,7 @@ describe('Account login history', () => { cy.wait(['@getProfile']); // Confirm helper text above table and table are not visible. - cy.findByText( - 'Logins across all users on your account over the last 90 days.' - ).should('not.exist'); + cy.findByText(loginHelperText).should('not.exist'); cy.findByLabelText('Account Logins').should('not.exist'); cy.findByText( @@ -137,13 +134,114 @@ describe('Account login history', () => { cy.wait(['@getProfile']); // Confirm helper text above table and table are not visible. - cy.findByText( - 'Logins across all users on your account over the last 90 days.' - ).should('not.exist'); + cy.findByText(loginHelperText).should('not.exist'); cy.findByLabelText('Account Logins').should('not.exist'); cy.findByText( "You don't have permissions to edit this Account. Please contact your account administrator to request the necessary permissions." ); }); + + /* + * - Vaildates login history landing page with mock data. + * - Confirms that each login is listed in the Login History table. + * - Confirms that "Successful" indicator is shown for successful login attempts, and the "Failure" indicator is shown for the failed ones. + * - Confirms that clicking on the username for the login navigates to the expected user page + */ + it('shows each login in the Login History landing page as expected', () => { + const mockProfile = profileFactory.build({ + username: 'mock-user', + restricted: false, + user_type: 'default', + }); + const mockFailedLogin = accountLoginFactory.build({ + status: 'failed', + username: 'mock-user-failed', + restricted: false, + }); + const mockSuccessfulLogin = accountLoginFactory.build({ + status: 'successful', + username: 'mock-user-successful', + restricted: false, + }); + + mockGetProfile(mockProfile).as('getProfile'); + mockGetAccountLogins([mockFailedLogin, mockSuccessfulLogin]).as( + 'getAccountLogins' + ); + + // Navigate to Account Login History page. + cy.visitWithLogin('/account/login-history'); + cy.wait(['@getProfile']); + + // Confirm helper text above table is visible. + cy.findByText(loginHelperText).should('be.visible'); + + // Confirm the login table includes the expected column headers and mocked logins are visible in table. + cy.findByLabelText('Account Logins').within(() => { + cy.get('thead').findByText('Date').should('be.visible'); + cy.get('thead').findByText('Username').should('be.visible'); + cy.get('thead').findByText('IP').should('be.visible'); + cy.get('thead').findByText('Permission Level').should('be.visible'); + cy.get('thead').findByText('Access').should('be.visible'); + + // Confirm that restricted user's failed login and status icon display in table. + cy.findByText(mockFailedLogin.username) + .should('be.visible') + .closest('tr') + .within(() => { + // cy.findByText(mockFailedLogin.status, { exact: false }).should( + // 'be.visible' + // ); + cy.findAllByLabelText(`Status is ${mockFailedLogin.status}`); + cy.findByText('Unrestricted').should('be.visible'); + }); + + // Confirm that unrestricted user login displays in table. + cy.findByText(mockSuccessfulLogin.username) + .should('be.visible') + .closest('tr') + .within(() => { + // Confirm that successful login and status icon display in table. + cy.findAllByLabelText(`Status is ${mockSuccessfulLogin.status}`); + + // Confirm all other fields display in table. + cy.findByText( + formatDate(mockSuccessfulLogin.datetime, { + timezone: mockProfile.timezone, + }) + ).should('be.visible'); + cy.findByText(mockSuccessfulLogin.ip).should('be.visible'); + cy.findByText('Unrestricted').should('be.visible'); + }); + }); + }); + + /* + * - Confirms that empty state is handled gracefully, showing corresponding message. + */ + it('shows empty message when there is no login history', () => { + mockGetAccountLogins([]).as('getAccountLogins'); + + // Navigate to Login History landing page. + cy.visitWithLogin('/account/login-history'); + cy.wait('@getAccountLogins'); + + // Confirm helper text above table is visible. + cy.findByText(loginHelperText).should('be.visible'); + + cy.findByLabelText('Account Logins').within(() => { + cy.get('thead').findByText('Date').should('be.visible'); + cy.get('thead').findByText('Username').should('be.visible'); + cy.get('thead').findByText('IP').should('be.visible'); + cy.get('thead').findByText('Permission Level').should('be.visible'); + cy.get('thead').findByText('Access').should('be.visible'); + }); + + cy.get('[data-testid="table-row-empty"]') + .should('be.visible') + .within(() => { + cy.findByText(loginEmptyStateMessageText).should('be.visible'); + }); + }); }); diff --git a/packages/manager/cypress/support/constants/account.ts b/packages/manager/cypress/support/constants/account.ts index 46396b7d895..2ef3525eaf7 100644 --- a/packages/manager/cypress/support/constants/account.ts +++ b/packages/manager/cypress/support/constants/account.ts @@ -18,3 +18,14 @@ We cannot cancel this account until the balance has been paid.'; */ export const sshFormatErrorMessage = 'SSH Key key-type must be ssh-dss, ssh-rsa, ecdsa-sha2-nistp, ssh-ed25519, or sk-ecdsa-sha2-nistp256.'; + +/** + * Helper text that appears above the login history table. + */ +export const loginHelperText = + 'Logins across all users on your account over the last 90 days.'; + +/** + * Empty state message that appears when there is no item in the login history table. + */ +export const loginEmptyStateMessageText = 'No account logins'; From 22a8bcfc8d6ff616e25a2a60c3f7b8ee32673f75 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Mon, 8 Jul 2024 17:43:44 -0400 Subject: [PATCH 159/163] upcoming: [M3-8328] - Add Analytics Events to Linode Create v2 (#10649) * send linode create event on creation * add analytics for Create using command line * Added changeset: Add Analytics Events to Linode Create v2 * pass form values insted of payload * lint * feedback @abailly-akamai --------- Co-authored-by: Banks Nussman --- ...r-10649-upcoming-features-1720453076417.md | 5 ++ .../Linodes/LinodeCreatev2/Actions.tsx | 5 +- .../features/Linodes/LinodeCreatev2/index.tsx | 19 +++++-- .../Linodes/LinodeCreatev2/utilities.ts | 53 +++++++++++++++++++ packages/manager/src/queries/stackscripts.ts | 2 +- .../analytics/customEventAnalytics.ts | 2 +- 6 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 packages/manager/.changeset/pr-10649-upcoming-features-1720453076417.md diff --git a/packages/manager/.changeset/pr-10649-upcoming-features-1720453076417.md b/packages/manager/.changeset/pr-10649-upcoming-features-1720453076417.md new file mode 100644 index 00000000000..3a047c5df55 --- /dev/null +++ b/packages/manager/.changeset/pr-10649-upcoming-features-1720453076417.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Analytics Events to Linode Create v2 ([#10649](https://github.com/linode/manager/pull/10649)) diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx index 557e9460766..8a996cf9417 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Actions.tsx @@ -1,15 +1,17 @@ -import { CreateLinodeRequest } from '@linode/api-v4'; import React, { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { Box } from 'src/components/Box'; import { Button } from 'src/components/Button/Button'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; +import { sendApiAwarenessClickEvent } from 'src/utilities/analytics/customEventAnalytics'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { ApiAwarenessModal } from '../LinodesCreate/ApiAwarenessModal/ApiAwarenessModal'; import { getLinodeCreatePayload } from './utilities'; +import type { CreateLinodeRequest } from '@linode/api-v4'; + export const Actions = () => { const [isAPIAwarenessModalOpen, setIsAPIAwarenessModalOpen] = useState(false); @@ -24,6 +26,7 @@ export const Actions = () => { }); const onOpenAPIAwareness = async () => { + sendApiAwarenessClickEvent('Button', 'Create Using Command Line'); if (await trigger()) { // If validation is successful, we open the dialog. setIsAPIAwarenessModalOpen(true); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx index b59ad02d374..7ebce1ebb7d 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/index.tsx @@ -1,4 +1,6 @@ import { isEmpty } from '@linode/api-v4'; +import { useQueryClient } from '@tanstack/react-query'; +import { useSnackbar } from 'notistack'; import React, { useEffect, useRef } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { useHistory } from 'react-router-dom'; @@ -35,6 +37,7 @@ import { Marketplace } from './Tabs/Marketplace/Marketplace'; import { StackScripts } from './Tabs/StackScripts/StackScripts'; import { UserData } from './UserData/UserData'; import { + captureLinodeCreateAnalyticsEvent, defaultValues, defaultValuesMap, getLinodeCreatePayload, @@ -50,7 +53,6 @@ import type { SubmitHandler } from 'react-hook-form'; export const LinodeCreatev2 = () => { const { params, setParams } = useLinodeCreateQueryParams(); - const formRef = useRef(null); const form = useForm({ defaultValues, @@ -60,9 +62,10 @@ export const LinodeCreatev2 = () => { }); const history = useHistory(); - + const queryClient = useQueryClient(); const { mutateAsync: createLinode } = useCreateLinodeMutation(); const { mutateAsync: cloneLinode } = useCloneLinodeMutation(); + const { enqueueSnackbar } = useSnackbar(); const currentTabIndex = getTabIndex(params.type); @@ -87,6 +90,16 @@ export const LinodeCreatev2 = () => { : await createLinode(payload); history.push(`/linodes/${linode.id}`); + + enqueueSnackbar(`Your Linode ${linode.label} is being created.`, { + variant: 'success', + }); + + captureLinodeCreateAnalyticsEvent({ + queryClient, + type: params.type ?? 'Distributions', + values, + }); } catch (errors) { for (const error of errors) { if (error.field) { @@ -118,7 +131,7 @@ export const LinodeCreatev2 = () => { docsLink="https://www.linode.com/docs/guides/platform/get-started/" title="Create" /> - + diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index c0e337e3e7d..f19c71ab0e9 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -2,6 +2,8 @@ import { getLinode, getStackScript } from '@linode/api-v4'; import { omit } from 'lodash'; import { useHistory } from 'react-router-dom'; +import { stackscriptQueries } from 'src/queries/stackscripts'; +import { sendCreateLinodeEvent } from 'src/utilities/analytics/customEventAnalytics'; import { privateIPRegex } from 'src/utilities/ipUtils'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; @@ -15,6 +17,7 @@ import type { InterfacePayload, Linode, } from '@linode/api-v4'; +import type { QueryClient } from '@tanstack/react-query'; /** * This is the ID of the Image of the default distribution. @@ -332,3 +335,53 @@ export const defaultValuesMap: Record = { 'One-Click': defaultValuesForStackScripts, StackScripts: defaultValuesForStackScripts, }; + +interface LinodeCreateAnalyticsEventOptions { + queryClient: QueryClient; + type: LinodeCreateType; + values: LinodeCreateFormValues; +} + +/** + * Captures a custom analytics event when a Linode is created. + */ +export const captureLinodeCreateAnalyticsEvent = async ( + options: LinodeCreateAnalyticsEventOptions +) => { + const { queryClient, type, values } = options; + + if (type === 'Backups' && values.backup_id) { + sendCreateLinodeEvent('backup', String(values.backup_id)); + } + + if (type === 'Clone Linode' && values.linode) { + const linodeId = values.linode.id; + // @todo use Linode query key factory when it is implemented + const linode = await queryClient.ensureQueryData({ + queryFn: () => getLinode(linodeId), + queryKey: ['linodes', 'linode', linodeId, 'details'], + }); + + sendCreateLinodeEvent('clone', values.type, { + isLinodePoweredOff: linode.status === 'offline', + }); + } + + if (type === 'Distributions' || type === 'Images') { + sendCreateLinodeEvent('image', values.image ?? undefined); + } + + if (type === 'StackScripts' && values.stackscript_id) { + const stackscript = await queryClient.ensureQueryData( + stackscriptQueries.stackscript(values.stackscript_id) + ); + sendCreateLinodeEvent('stackscript', stackscript.label); + } + + if (type === 'One-Click' && values.stackscript_id) { + const stackscript = await queryClient.ensureQueryData( + stackscriptQueries.stackscript(values.stackscript_id) + ); + sendCreateLinodeEvent('one-click', stackscript.label); + } +}; diff --git a/packages/manager/src/queries/stackscripts.ts b/packages/manager/src/queries/stackscripts.ts index 9dd389ff268..f1c1576faea 100644 --- a/packages/manager/src/queries/stackscripts.ts +++ b/packages/manager/src/queries/stackscripts.ts @@ -24,7 +24,7 @@ export const getAllOCAsRequest = (passedParams: Params = {}) => getOneClickApps({ ...params, ...passedParams }) )().then((data) => data.data); -const stackscriptQueries = createQueryKeys('stackscripts', { +export const stackscriptQueries = createQueryKeys('stackscripts', { infinite: (filter: Filter = {}) => ({ queryFn: ({ pageParam }) => getStackScripts({ page: pageParam, page_size: 25 }, filter), diff --git a/packages/manager/src/utilities/analytics/customEventAnalytics.ts b/packages/manager/src/utilities/analytics/customEventAnalytics.ts index 2091f5578dd..5a9b787e92e 100644 --- a/packages/manager/src/utilities/analytics/customEventAnalytics.ts +++ b/packages/manager/src/utilities/analytics/customEventAnalytics.ts @@ -113,7 +113,7 @@ export const sendCreateNodeBalancerEvent = (eventLabel: string): void => { // LinodeCreateContainer.tsx export const sendCreateLinodeEvent = ( eventAction: string, - eventLabel: string, + eventLabel: string | undefined, eventData?: CustomAnalyticsData ): void => { sendEvent({ From 8e568bc26449e108ab988edde24b7ea0e95cdcbe Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-linode@users.noreply.github.com> Date: Tue, 9 Jul 2024 10:37:13 -0400 Subject: [PATCH 160/163] fix: Github CLI install link in Contributing guide (#10657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Fix the github CLI link in our contributing guide leading to a 404 error ## How to test 🧪 ### Reproduction steps (How to reproduce the issue, if applicable) - Go to the old link https://cli.github.com/manual/installation and notice a 404 error ### Verification steps (How to verify changes) - Go to the new link https://github.com/cli/cli#installation, there should not be a 404 error --- docs/CONTRIBUTING.md | 2 +- packages/manager/.changeset/pr-10657-fixed-1720475119486.md | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-10657-fixed-1720475119486.md diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index e4197539522..129cbb844e8 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -29,7 +29,7 @@ Feel free to open an issue to report a bug or request a feature. 6. Open a pull request against `develop` and make sure the title follows the same format as the commit message. 7. If needed, create a changeset to populate our changelog - If you don't have the Github CLI installed or need to update it (you need GH CLI 2.21.0 or greater), - - install it via `brew`: https://cli.github.com/manual/installation or upgrade with `brew upgrade gh` + - install it via `brew`: https://github.com/cli/cli#installation or upgrade with `brew upgrade gh` - Once installed, run `gh repo set-default` and pick `linode/manager` (only > 2.21.0) - You can also just create the changeset manually, in this case make sure to use the proper formatting for it. - Run `yarn changeset`from the root, choose the package to create a changeset for, and provide a description for the change. diff --git a/packages/manager/.changeset/pr-10657-fixed-1720475119486.md b/packages/manager/.changeset/pr-10657-fixed-1720475119486.md new file mode 100644 index 00000000000..56b96bcb2ea --- /dev/null +++ b/packages/manager/.changeset/pr-10657-fixed-1720475119486.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Github CLI install link in Contributing guide ([#10657](https://github.com/linode/manager/pull/10657)) From 395434fdc0588bab128bd81ff8f364dedb4768c9 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Tue, 9 Jul 2024 13:41:24 -0400 Subject: [PATCH 161/163] feat:[M3-8330] - Improve API flexibility for useToastNotification (#10654) Co-authored-by: Jaalah Ramos --- .../pr-10654-tech-stories-1720468649871.md | 5 + .../src/hooks/useToastNotifications.tsx | 276 +++++++++++------- 2 files changed, 175 insertions(+), 106 deletions(-) create mode 100644 packages/manager/.changeset/pr-10654-tech-stories-1720468649871.md diff --git a/packages/manager/.changeset/pr-10654-tech-stories-1720468649871.md b/packages/manager/.changeset/pr-10654-tech-stories-1720468649871.md new file mode 100644 index 00000000000..0df861ae755 --- /dev/null +++ b/packages/manager/.changeset/pr-10654-tech-stories-1720468649871.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Improve API flexibility for useToastNotification ([#10654](https://github.com/linode/manager/pull/10654)) diff --git a/packages/manager/src/hooks/useToastNotifications.tsx b/packages/manager/src/hooks/useToastNotifications.tsx index fca04d10fd9..d62593921b7 100644 --- a/packages/manager/src/hooks/useToastNotifications.tsx +++ b/packages/manager/src/hooks/useToastNotifications.tsx @@ -1,4 +1,3 @@ -import { Event, EventAction } from '@linode/api-v4/lib/account/types'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -6,6 +5,8 @@ import { Link } from 'src/components/Link'; import { SupportLink } from 'src/components/SupportLink'; import { sendLinodeDiskEvent } from 'src/utilities/analytics/customEventAnalytics'; +import type { Event, EventAction } from '@linode/api-v4/lib/account/types'; + export const getLabel = (event: Event) => event.entity?.label ?? ''; export const getSecondaryLabel = (event: Event) => event.secondary_entity?.label ?? ''; @@ -17,11 +18,19 @@ const formatLink = (text: string, link: string, handleClick?: () => void) => { ); }; -interface Toast { - failure?: ((event: Event) => string | undefined) | string; +interface ToastMessage { link?: JSX.Element; - persistFailureMessage?: boolean; - success?: ((event: Event) => string | undefined) | string; + message: ((event: Event) => string | undefined) | string; + persist?: boolean; +} + +interface Toast { + failure?: ToastMessage; + /** + * If true, the toast will be displayed with an error variant. + */ + invertVariant?: boolean; + success?: ToastMessage; } type Toasts = { @@ -34,165 +43,220 @@ type Toasts = { * * Use this feature to notify users of *asynchronous tasks* such as migrating a Linode. * - * DO NOT use this feature to notifiy the user of tasks like changing the label of a Linode. - * Toasts for that can be handeled at the time of making the PUT request. + * DO NOT use this feature to notify the user of tasks like changing the label of a Linode. + * Toasts for that can be handled at the time of making the PUT request. */ const toasts: Toasts = { backups_restore: { - failure: (e) => `Backup restoration failed for ${getLabel(e)}.`, - link: formatLink( - 'Learn more about limits and considerations.', - 'https://www.linode.com/docs/products/storage/backups/#limits-and-considerations' - ), - persistFailureMessage: true, + failure: { + link: formatLink( + 'Learn more about limits and considerations.', + 'https://www.linode.com/docs/products/storage/backups/#limits-and-considerations' + ), + message: (e) => `Backup restoration failed for ${getLabel(e)}.`, + persist: true, + }, }, disk_delete: { - failure: (e) => - `Unable to delete disk ${getSecondaryLabel(e)} ${ - getLabel(e) ? ` on ${getLabel(e)}` : '' - }. Is it attached to a configuration profile that is in use?`, - success: (e) => `Disk ${getSecondaryLabel(e)} successfully deleted.`, + failure: { + message: (e) => + `Unable to delete disk ${getSecondaryLabel(e)} ${ + getLabel(e) ? ` on ${getLabel(e)}` : '' + }. Is it attached to a configuration profile that is in use?`, + }, + success: { + message: (e) => `Disk ${getSecondaryLabel(e)} successfully deleted.`, + }, }, disk_imagize: { - failure: (e) => - `There was a problem creating Image ${getSecondaryLabel(e)}.`, - link: formatLink( - 'Learn more about image technical specifications.', - 'https://www.linode.com/docs/products/tools/images/#technical-specifications' - ), - persistFailureMessage: true, - success: (e) => `Image ${getSecondaryLabel(e)} successfully created.`, + failure: { + link: formatLink( + 'Learn more about image technical specifications.', + 'https://www.linode.com/docs/products/tools/images/#technical-specifications' + ), + message: (e) => + `There was a problem creating Image ${getSecondaryLabel(e)}.`, + persist: true, + }, + + success: { + message: (e) => `Image ${getSecondaryLabel(e)} successfully created.`, + }, }, disk_resize: { - failure: `Disk resize failed.`, - link: formatLink( - 'Learn more about resizing restrictions.', - 'https://www.linode.com/docs/products/compute/compute-instances/guides/disks-and-storage/', - () => - sendLinodeDiskEvent('Resize', 'Click:link', 'Disk resize failed toast') - ), - persistFailureMessage: true, - success: (e) => `Disk ${getSecondaryLabel(e)} successfully resized.`, + failure: { + link: formatLink( + 'Learn more about resizing restrictions.', + 'https://www.linode.com/docs/products/compute/compute-instances/guides/disks-and-storage/', + () => + sendLinodeDiskEvent( + 'Resize', + 'Click:link', + 'Disk resize failed toast' + ) + ), + message: `Disk resize failed.`, + persist: true, + }, + success: { + message: (e) => `Disk ${getSecondaryLabel(e)} successfully resized.`, + }, }, image_delete: { - failure: (e) => `Error deleting Image ${getLabel(e)}.`, - success: (e) => `Image ${getLabel(e)} successfully deleted.`, + failure: { message: (e) => `Error deleting Image ${getLabel(e)}.` }, + success: { message: (e) => `Image ${getLabel(e)} successfully deleted.` }, }, image_upload: { - failure(event) { - const isDeletion = event.message === 'Upload canceled.'; + failure: { + message: (e) => { + const isDeletion = e.message === 'Upload canceled.'; - if (isDeletion) { - return undefined; - } + if (isDeletion) { + return undefined; + } - return `There was a problem uploading image ${getLabel( - event - )}: ${event.message?.replace(/(\d+)/g, '$1 MB')}`; + return `There was a problem uploading image ${getLabel( + e + )}: ${e.message?.replace(/(\d+)/g, '$1 MB')}`; + }, + persist: true, }, - persistFailureMessage: true, - success: (e) => `Image ${getLabel(e)} is now available.`, + success: { message: (e) => `Image ${getLabel(e)} is now available.` }, }, linode_clone: { - failure: (e) => `Error cloning Linode ${getLabel(e)}.`, - success: (e) => - `Linode ${getLabel(e)} successfully cloned to ${getSecondaryLabel(e)}.`, + failure: { message: (e) => `Error cloning Linode ${getLabel(e)}.` }, + success: { + message: (e) => + `Linode ${getLabel(e)} successfully cloned to ${getSecondaryLabel(e)}.`, + }, }, linode_migrate: { - failure: (e) => `Error migrating Linode ${getLabel(e)}.`, - success: (e) => `Linode ${getLabel(e)} successfully migrated.`, + failure: { message: (e) => `Error migrating Linode ${getLabel(e)}.` }, + success: { message: (e) => `Linode ${getLabel(e)} successfully migrated.` }, }, linode_migrate_datacenter: { - failure: (e) => `Error migrating Linode ${getLabel(e)}.`, - success: (e) => `Linode ${getLabel(e)} successfully migrated.`, + failure: { message: (e) => `Error migrating Linode ${getLabel(e)}.` }, + success: { message: (e) => `Linode ${getLabel(e)} successfully migrated.` }, }, linode_resize: { - failure: (e) => `Error resizing Linode ${getLabel(e)}.`, - success: (e) => `Linode ${getLabel(e)} successfully resized.`, + failure: { message: (e) => `Error resizing Linode ${getLabel(e)}.` }, + success: { message: (e) => `Linode ${getLabel(e)} successfully resized.` }, }, linode_snapshot: { - failure: (e) => `Snapshot backup failed on Linode ${getLabel(e)}.`, - link: formatLink( - 'Learn more about limits and considerations.', - 'https://www.linode.com/docs/products/storage/backups/#limits-and-considerations' - ), - persistFailureMessage: true, + failure: { + link: formatLink( + 'Learn more about limits and considerations.', + 'https://www.linode.com/docs/products/storage/backups/#limits-and-considerations' + ), + message: (e) => `Snapshot backup failed on Linode ${getLabel(e)}.`, + persist: true, + }, }, longviewclient_create: { - failure: (e) => `Error creating Longview Client ${getLabel(e)}.`, - success: (e) => `Longview Client ${getLabel(e)} successfully created.`, + failure: { + message: (e) => `Error creating Longview Client ${getLabel(e)}.`, + }, + success: { + message: (e) => `Longview Client ${getLabel(e)} successfully created.`, + }, + }, + tax_id_invalid: { + failure: { message: 'Error validating Tax Identification Number.' }, + invertVariant: true, + success: { + message: 'Tax Identification Number could not be verified.', + persist: true, + }, }, volume_attach: { - failure: (e) => `Error attaching Volume ${getLabel(e)}.`, - success: (e) => `Volume ${getLabel(e)} successfully attached.`, + failure: { message: (e) => `Error attaching Volume ${getLabel(e)}.` }, + success: { message: (e) => `Volume ${getLabel(e)} successfully attached.` }, }, volume_create: { - failure: (e) => `Error creating Volume ${getLabel(e)}.`, - success: (e) => `Volume ${getLabel(e)} successfully created.`, + failure: { message: (e) => `Error creating Volume ${getLabel(e)}.` }, + success: { message: (e) => `Volume ${getLabel(e)} successfully created.` }, }, volume_delete: { - failure: 'Error deleting Volume.', - success: 'Volume successfully deleted.', + failure: { message: 'Error deleting Volume.' }, + success: { message: 'Volume successfully deleted.' }, }, volume_detach: { - failure: (e) => `Error detaching Volume ${getLabel(e)}.`, - success: (e) => `Volume ${getLabel(e)} successfully detached.`, + failure: { message: (e) => `Error detaching Volume ${getLabel(e)}.` }, + success: { message: (e) => `Volume ${getLabel(e)} successfully detached.` }, }, volume_migrate: { - failure: (e) => `Error upgrading Volume ${getLabel(e)}.`, - success: (e) => `Volume ${getLabel(e)} successfully upgraded.`, + failure: { message: (e) => `Error upgrading Volume ${getLabel(e)}.` }, + success: { message: (e) => `Volume ${getLabel(e)} successfully upgraded.` }, }, }; -export const useToastNotifications = () => { +const getToastMessage = ( + toastMessage: ((event: Event) => string | undefined) | string, + event: Event +): string | undefined => + typeof toastMessage === 'function' ? toastMessage(event) : toastMessage; + +const createFormattedMessage = ( + message: string | undefined, + link: JSX.Element | undefined, + hasSupportLink: boolean +) => ( + <> + {message?.replace(/ contact Support/i, '') ?? message} + {hasSupportLink && ( + <> +   + . + + )} + {link && <> {link}} + +); + +export const useToastNotifications = (): { + handleGlobalToast: (event: Event) => void; +} => { const { enqueueSnackbar } = useSnackbar(); - const handleGlobalToast = (event: Event) => { + const handleGlobalToast = (event: Event): void => { const toastInfo = toasts[event.action]; - if (!toastInfo) { return; } - if ( - ['finished', 'notification'].includes(event.status) && - toastInfo.success - ) { - const successMessage = - typeof toastInfo.success === 'function' - ? toastInfo.success(event) - : toastInfo.success; - - enqueueSnackbar(successMessage, { - variant: 'success', + const isSuccessEvent = ['finished', 'notification'].includes(event.status); + + if (isSuccessEvent && toastInfo.success) { + const { link, message, persist } = toastInfo.success; + const successMessage = getToastMessage(message, event); + + const formattedSuccessMessage = createFormattedMessage( + successMessage, + link, + false + ); + + enqueueSnackbar(formattedSuccessMessage, { + persist: persist ?? false, + variant: toastInfo.invertVariant ? 'error' : 'success', }); } if (event.status === 'failed' && toastInfo.failure) { - const failureMessage = - typeof toastInfo.failure === 'function' - ? toastInfo.failure(event) - : toastInfo.failure; - + const { link, message, persist } = toastInfo.failure; + const failureMessage = getToastMessage(message, event); const hasSupportLink = failureMessage?.includes('contact Support') ?? false; - const formattedFailureMessage = ( - <> - {failureMessage?.replace(/ contact Support/i, '') ?? failureMessage} - {hasSupportLink ? ( - <> -   - . - - ) : null} - {toastInfo.link ? <> {toastInfo.link} : null} - + const formattedFailureMessage = createFormattedMessage( + failureMessage, + link, + hasSupportLink ); enqueueSnackbar(formattedFailureMessage, { - persist: toastInfo.persistFailureMessage, - variant: 'error', + persist: persist ?? false, + variant: toastInfo.invertVariant ? 'success' : 'error', }); } }; From c632152e5f74dac509510bbd1608623abde9ebc2 Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Tue, 9 Jul 2024 13:25:53 -0700 Subject: [PATCH 162/163] change: [M3-8329] - Clean up Gravatar analytics events (#10661) * Clean up Gravatar analytics events * Added changeset: Gravatar analytics events --- .../pr-10661-removed-1720547888843.md | 5 +++ .../src/components/GravatarByEmail.tsx | 39 ------------------- .../DisplaySettings/DisplaySettings.tsx | 10 ++--- .../features/TopMenu/UserMenu/UserMenu.tsx | 2 +- .../analytics/customEventAnalytics.ts | 18 --------- 5 files changed, 9 insertions(+), 65 deletions(-) create mode 100644 packages/manager/.changeset/pr-10661-removed-1720547888843.md diff --git a/packages/manager/.changeset/pr-10661-removed-1720547888843.md b/packages/manager/.changeset/pr-10661-removed-1720547888843.md new file mode 100644 index 00000000000..9d8fdf57c21 --- /dev/null +++ b/packages/manager/.changeset/pr-10661-removed-1720547888843.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Removed +--- + +Gravatar analytics events ([#10661](https://github.com/linode/manager/pull/10661)) diff --git a/packages/manager/src/components/GravatarByEmail.tsx b/packages/manager/src/components/GravatarByEmail.tsx index c26deff5c3c..ea345f14cc2 100644 --- a/packages/manager/src/components/GravatarByEmail.tsx +++ b/packages/manager/src/components/GravatarByEmail.tsx @@ -2,18 +2,11 @@ import Avatar from '@mui/material/Avatar'; import * as React from 'react'; import UserIcon from 'src/assets/icons/account.svg'; -import { sendHasGravatarEvent } from 'src/utilities/analytics/customEventAnalytics'; -import { waitForAdobeAnalyticsToBeLoaded } from 'src/utilities/analytics/utils'; import { getGravatarUrl } from 'src/utilities/gravatar'; export const DEFAULT_AVATAR_SIZE = 28; interface Props { - /** - * Captures a "has gravatar" event when when the component is mounted - * @default false - */ - captureAnalytics?: boolean; className?: string; email: string; height?: number; @@ -22,7 +15,6 @@ interface Props { export const GravatarByEmail = (props: Props) => { const { - captureAnalytics, className, email, height = DEFAULT_AVATAR_SIZE, @@ -31,12 +23,6 @@ export const GravatarByEmail = (props: Props) => { const url = getGravatarUrl(email); - React.useEffect(() => { - if (captureAnalytics) { - checkForGravatarAndSendEvent(url); - } - }, []); - return ( { ); }; - -/** - * Given a Gravatar URL, this function waits for Adobe Analytics - * to load (if it is not already loaded) and captures an Analytics - * event saying whether or not the user has a Gravatar. - * - * Make sure the URL passed has `?d=404` - */ -async function checkForGravatarAndSendEvent(url: string) { - try { - await waitForAdobeAnalyticsToBeLoaded(); - - const response = await fetch(url); - - if (response.status === 200) { - sendHasGravatarEvent(true); - } - if (response.status === 404) { - sendHasGravatarEvent(false); - } - } catch (error) { - // Analytics didn't load or the fetch to Gravatar - // failed. Event won't be logged. - } -} diff --git a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx index 564aae521ff..1a1ba9ae96b 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx @@ -16,11 +16,11 @@ import { Typography } from 'src/components/Typography'; import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; import { useNotificationsQuery } from 'src/queries/account/notifications'; import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; -import { ApplicationState } from 'src/store'; -import { sendManageGravatarEvent } from 'src/utilities/analytics/customEventAnalytics'; import { TimezoneForm } from './TimezoneForm'; +import type { ApplicationState } from 'src/store'; + export const DisplaySettings = () => { const theme = useTheme(); const { mutateAsync: updateProfile } = useMutateProfile(); @@ -111,11 +111,7 @@ export const DisplaySettings = () => { Create, upload, and manage your globally recognized avatar from a single place with Gravatar. - sendManageGravatarEvent()} - to="https://en.gravatar.com/" - > + Manage photo

    diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index 61218835c21..f6d9ee4c6b2 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -209,7 +209,7 @@ export const UserMenu = React.memo(() => { isProxyUser ? ( ) : ( - + ) } sx={(theme) => ({ diff --git a/packages/manager/src/utilities/analytics/customEventAnalytics.ts b/packages/manager/src/utilities/analytics/customEventAnalytics.ts index 5a9b787e92e..b6e49e2e4a5 100644 --- a/packages/manager/src/utilities/analytics/customEventAnalytics.ts +++ b/packages/manager/src/utilities/analytics/customEventAnalytics.ts @@ -452,24 +452,6 @@ export const sendUpdateLinodeLabelEvent = ( }); }; -// GravatarByEmail.tsx -export const sendHasGravatarEvent = (hasGravatar: boolean) => { - sendEvent({ - action: 'Load', - category: 'Gravatar', - label: hasGravatar ? 'Has Gravatar' : 'Does not have Gravatar', - }); -}; - -// DisplaySettings.tsx -export const sendManageGravatarEvent = () => { - sendEvent({ - action: 'Click:link', - category: 'Gravatar', - label: 'Manage photo', - }); -}; - // SelectLinodePanel.tsx // LinodeSelectTable.tsx export const sendLinodePowerOffEvent = (category: string) => { From facfc627ff851753fd92716ddfcde1a20805d22a Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Wed, 10 Jul 2024 11:27:08 +0530 Subject: [PATCH 163/163] fix: [M3-6518] - LKE details page "Delete Pool" button misaligned (#10660) * Remove unnecessary bottom margin for the delete pool btn * Added changeset: Fix LKE details page 'Delete Pool' button misalignment * Update packages/manager/.changeset/pr-10660-fixed-1720504089793.md Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --------- Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- packages/manager/.changeset/pr-10660-fixed-1720504089793.md | 5 +++++ .../KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-10660-fixed-1720504089793.md diff --git a/packages/manager/.changeset/pr-10660-fixed-1720504089793.md b/packages/manager/.changeset/pr-10660-fixed-1720504089793.md new file mode 100644 index 00000000000..52e0fdddff4 --- /dev/null +++ b/packages/manager/.changeset/pr-10660-fixed-1720504089793.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +LKE details page 'Delete Pool' button misalignment ([#10660](https://github.com/linode/manager/pull/10660)) diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx index 02e61f7aec2..3d2a6e72708 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/NodePool.tsx @@ -38,7 +38,6 @@ const useStyles = makeStyles()((theme: Theme) => ({ paddingRight: 8, }, deletePoolBtn: { - marginBottom: 3, paddingRight: 8, }, }));