From 8176f7a7b5bf75b16d0b18d25255203e7e174f20 Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 29 Aug 2024 09:57:17 -0400 Subject: [PATCH 01/67] change: [M3-8425, M3-8231] - Update TypeScript and Vitest (#10843) * update a lot of stuff * Added changeset: Update TypeScript and Vitest to latest * Added changeset: Updated vitest to latest --------- Co-authored-by: Banks Nussman --- package.json | 4 +- .../pr-10843-tech-stories-1724855530119.md | 5 + packages/api-v4/package.json | 2 +- .../pr-10843-tech-stories-1724855361654.md | 5 + packages/manager/package.json | 6 +- packages/search/package.json | 2 +- yarn.lock | 709 +++++++++--------- 7 files changed, 373 insertions(+), 360 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10843-tech-stories-1724855530119.md create mode 100644 packages/manager/.changeset/pr-10843-tech-stories-1724855361654.md diff --git a/package.json b/package.json index aec58e96361..e24b790f9ec 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,7 @@ "license": "Apache-2.0", "devDependencies": { "husky": "^3.0.1", - "npm-run-all": "^4.1.5", - "postinstall": "^0.6.0", - "typescript": "^5.4.5" + "typescript": "^5.5.4" }, "husky": { "hooks": { diff --git a/packages/api-v4/.changeset/pr-10843-tech-stories-1724855530119.md b/packages/api-v4/.changeset/pr-10843-tech-stories-1724855530119.md new file mode 100644 index 00000000000..dd242ac9d12 --- /dev/null +++ b/packages/api-v4/.changeset/pr-10843-tech-stories-1724855530119.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Tech Stories +--- + +Updated vitest to latest ([#10843](https://github.com/linode/manager/pull/10843)) diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 2a590b140f7..a3e1333ae49 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -67,7 +67,7 @@ "lint-staged": "^15.2.9", "prettier": "~2.2.1", "tsup": "^8.2.4", - "vitest": "^1.6.0" + "vitest": "^2.0.5" }, "lint-staged": { "*.{ts,tsx,js}": [ diff --git a/packages/manager/.changeset/pr-10843-tech-stories-1724855361654.md b/packages/manager/.changeset/pr-10843-tech-stories-1724855361654.md new file mode 100644 index 00000000000..3b589f4f2bb --- /dev/null +++ b/packages/manager/.changeset/pr-10843-tech-stories-1724855361654.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Update TypeScript and Vitest to latest ([#10843](https://github.com/linode/manager/pull/10843)) diff --git a/packages/manager/package.json b/packages/manager/package.json index a7134241805..875cde6d5b1 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -168,8 +168,8 @@ "@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", + "@vitest/coverage-v8": "^2.0.5", + "@vitest/ui": "^2.0.5", "chai-string": "^1.5.0", "chalk": "^5.2.0", "commander": "^6.2.1", @@ -213,7 +213,7 @@ "ts-node": "^10.9.2", "vite": "^5.1.7", "vite-plugin-svgr": "^3.2.0", - "vitest": "^1.6.0" + "vitest": "^2.0.5" }, "browserslist": [ ">1%", diff --git a/packages/search/package.json b/packages/search/package.json index 7445271aa83..9d06a976423 100644 --- a/packages/search/package.json +++ b/packages/search/package.json @@ -20,6 +20,6 @@ "vite": "*" }, "devDependencies": { - "vitest": "^1.6.0" + "vitest": "^2.0.5" } } diff --git a/yarn.lock b/yarn.lock index 8c0219aad8e..333b005a083 100644 --- a/yarn.lock +++ b/yarn.lock @@ -116,7 +116,7 @@ "@algolia/logger-common" "4.22.1" "@algolia/requester-common" "4.22.1" -"@ampproject/remapping@^2.2.0", "@ampproject/remapping@^2.2.1": +"@ampproject/remapping@^2.2.0": version "2.2.1" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.1.tgz#99e8e11851128b8702cd57c33684f1d0f260b630" integrity sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg== @@ -124,6 +124,14 @@ "@jridgewell/gen-mapping" "^0.3.0" "@jridgewell/trace-mapping" "^0.3.9" +"@ampproject/remapping@^2.3.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" + integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + "@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" @@ -552,7 +560,7 @@ 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": +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.23.0", "@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" integrity sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA== @@ -574,6 +582,13 @@ dependencies: "@babel/types" "^7.25.2" +"@babel/parser@^7.25.4": + version "7.25.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.25.4.tgz#af4f2df7d02440286b7de57b1c21acfb2a6f257a" + integrity sha512-nq+eWrOgdtu3jG5Os4TQP3x3cLA8hR8TvJNjD8vnPa20WGycimcparWnLK4jJhElTK6SDyuJo1weMKO/5LpmLA== + dependencies: + "@babel/types" "^7.25.4" + "@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" @@ -1421,6 +1436,15 @@ "@babel/helper-validator-identifier" "^7.24.7" to-fast-properties "^2.0.0" +"@babel/types@^7.25.4": + version "7.25.4" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.25.4.tgz#6bcb46c72fdf1012a209d016c07f769e10adcb5f" + integrity sha512-zQ1ijeeCXVEh+aNL0RlmkPkG8HUiDcU2pzQQFjtbntgAczRASFzj4H+6+bV+dy1ntKR14I/DypeuRG1uma98iQ== + dependencies: + "@babel/helper-string-parser" "^7.24.8" + "@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" @@ -2266,13 +2290,6 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== -"@jest/schemas@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" - integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== - dependencies: - "@sinclair/typebox" "^0.27.8" - "@joshwooding/vite-plugin-react-docgen-typescript@0.3.1": version "0.3.1" resolved "https://registry.yarnpkg.com/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.3.1.tgz#a733e7fc90c00ce694058d3af034b9f63d88cddd" @@ -2321,6 +2338,11 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== +"@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + "@jridgewell/trace-mapping@0.3.9": version "0.3.9" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" @@ -2661,6 +2683,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.0.tgz#d941173f82f9b041c61b0dc1a2a91dcd06e4b31e" integrity sha512-WTWD8PfoSAJ+qL87lE7votj3syLavxunWhzCnx3XFxFiI/BA/r3X7MUM8dVrH8rb2r4AiO8jJsr3ZjdaftmnfA== +"@rollup/rollup-android-arm-eabi@4.21.1": + version "4.21.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.1.tgz#c3a7938551273a2b72820cf5d22e54cf41dc206e" + integrity sha512-2thheikVEuU7ZxFXubPDOtspKn1x0yqaYQwvALVtEcvFhMifPADBrgRPyHV0TF3b+9BgvgjgagVyvA/UqPZHmg== + "@rollup/rollup-android-arm-eabi@4.9.6": version "4.9.6" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.6.tgz#66b8d9cb2b3a474d115500f9ebaf43e2126fe496" @@ -2671,6 +2698,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.0.tgz#7e7157c8543215245ceffc445134d9e843ba51c0" integrity sha512-a1sR2zSK1B4eYkiZu17ZUZhmUQcKjk2/j9Me2IDjk1GHW7LB5Z35LEzj9iJch6gtUfsnvZs1ZNyDW2oZSThrkA== +"@rollup/rollup-android-arm64@4.21.1": + version "4.21.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.1.tgz#fa3693e4674027702c42fcbbb86bbd0c635fd3b9" + integrity sha512-t1lLYn4V9WgnIFHXy1d2Di/7gyzBWS8G5pQSXdZqfrdCGTwi1VasRMSS81DTYb+avDs/Zz4A6dzERki5oRYz1g== + "@rollup/rollup-android-arm64@4.9.6": version "4.9.6" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.6.tgz#46327d5b86420d2307946bec1535fdf00356e47d" @@ -2681,6 +2713,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.0.tgz#f0a18a4fc8dc6eb1e94a51fa2adb22876f477947" integrity sha512-zOnKWLgDld/svhKO5PD9ozmL6roy5OQ5T4ThvdYZLpiOhEGY+dp2NwUmxK0Ld91LrbjrvtNAE0ERBwjqhZTRAA== +"@rollup/rollup-darwin-arm64@4.21.1": + version "4.21.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.1.tgz#e19922f4ac1e4552a230ff8f49d5688c5c07d284" + integrity sha512-AH/wNWSEEHvs6t4iJ3RANxW5ZCK3fUnmf0gyMxWCesY1AlUj8jY7GC+rQE4wd3gwmZ9XDOpL0kcFnCjtN7FXlA== + "@rollup/rollup-darwin-arm64@4.9.6": version "4.9.6" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.6.tgz#166987224d2f8b1e2fd28ee90c447d52271d5e90" @@ -2691,6 +2728,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.0.tgz#34b7867613e5cc42d2b85ddc0424228cc33b43f0" integrity sha512-7doS8br0xAkg48SKE2QNtMSFPFUlRdw9+votl27MvT46vo44ATBmdZdGysOevNELmZlfd+NEa0UYOA8f01WSrg== +"@rollup/rollup-darwin-x64@4.21.1": + version "4.21.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.1.tgz#897f8d47b115ea84692a29cf2366899499d4d915" + integrity sha512-dO0BIz/+5ZdkLZrVgQrDdW7m2RkrLwYTh2YMFG9IpBtlC1x1NPNSXkfczhZieOlOLEqgXOFH3wYHB7PmBtf+Bg== + "@rollup/rollup-darwin-x64@4.9.6": version "4.9.6" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.6.tgz#a2e6e096f74ccea6e2f174454c26aef6bcdd1274" @@ -2701,6 +2743,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.0.tgz#422b19ff9ae02b05d3395183d1d43b38c7c8be0b" integrity sha512-pWJsfQjNWNGsoCq53KjMtwdJDmh/6NubwQcz52aEwLEuvx08bzcy6tOUuawAOncPnxz/3siRtd8hiQ32G1y8VA== +"@rollup/rollup-linux-arm-gnueabihf@4.21.1": + version "4.21.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.1.tgz#7d1e2a542f3a5744f5c24320067bd5af99ec9d62" + integrity sha512-sWWgdQ1fq+XKrlda8PsMCfut8caFwZBmhYeoehJ05FdI0YZXk6ZyUjWLrIgbR/VgiGycrFKMMgp7eJ69HOF2pQ== + "@rollup/rollup-linux-arm-gnueabihf@4.9.6": version "4.9.6" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.6.tgz#09fcd4c55a2d6160c5865fec708a8e5287f30515" @@ -2711,11 +2758,21 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.0.tgz#568aa29195ef6fc57ec6ed3f518923764406a8ee" integrity sha512-efRIANsz3UHZrnZXuEvxS9LoCOWMGD1rweciD6uJQIx2myN3a8Im1FafZBzh7zk1RJ6oKcR16dU3UPldaKd83w== +"@rollup/rollup-linux-arm-musleabihf@4.21.1": + version "4.21.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.1.tgz#88bec1c9df85fc5e24d49f783e19934717dd69b5" + integrity sha512-9OIiSuj5EsYQlmwhmFRA0LRO0dRRjdCVZA3hnmZe1rEwRk11Jy3ECGGq3a7RrVEZ0/pCsYWx8jG3IvcrJ6RCew== + "@rollup/rollup-linux-arm64-gnu@4.21.0": version "4.21.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.0.tgz#22309c8bcba9a73114f69165c72bc94b2fbec085" integrity sha512-ZrPhydkTVhyeGTW94WJ8pnl1uroqVHM3j3hjdquwAcWnmivjAwOYjTEAuEDeJvGX7xv3Z9GAvrBkEzCgHq9U1w== +"@rollup/rollup-linux-arm64-gnu@4.21.1": + version "4.21.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.1.tgz#6dc60f0fe7bd49ed07a2d4d9eab15e671b3bd59d" + integrity sha512-0kuAkRK4MeIUbzQYu63NrJmfoUVicajoRAL1bpwdYIYRcs57iyIV9NLcuyDyDXE2GiZCL4uhKSYAnyWpjZkWow== + "@rollup/rollup-linux-arm64-gnu@4.9.6": version "4.9.6" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.6.tgz#19a3c0b6315c747ca9acf86e9b710cc2440f83c9" @@ -2726,6 +2783,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.0.tgz#c93c388af6d33f082894b8a60839d7265b2b9bc5" integrity sha512-cfaupqd+UEFeURmqNP2eEvXqgbSox/LHOyN9/d2pSdV8xTrjdg3NgOFJCtc1vQ/jEke1qD0IejbBfxleBPHnPw== +"@rollup/rollup-linux-arm64-musl@4.21.1": + version "4.21.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.1.tgz#a03b78775c129e8333aca9e1e420e8e217ee99b9" + integrity sha512-/6dYC9fZtfEY0vozpc5bx1RP4VrtEOhNQGb0HwvYNwXD1BBbwQ5cKIbUVVU7G2d5WRE90NfB922elN8ASXAJEA== + "@rollup/rollup-linux-arm64-musl@4.9.6": version "4.9.6" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.6.tgz#94aaf95fdaf2ad9335983a4552759f98e6b2e850" @@ -2736,11 +2798,21 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.0.tgz#493c5e19e395cf3c6bd860c7139c8a903dea72b4" integrity sha512-ZKPan1/RvAhrUylwBXC9t7B2hXdpb/ufeu22pG2psV7RN8roOfGurEghw1ySmX/CmDDHNTDDjY3lo9hRlgtaHg== +"@rollup/rollup-linux-powerpc64le-gnu@4.21.1": + version "4.21.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.1.tgz#ee3810647faf2c105a5a4e71260bb90b96bf87bc" + integrity sha512-ltUWy+sHeAh3YZ91NUsV4Xg3uBXAlscQe8ZOXRCVAKLsivGuJsrkawYPUEyCV3DYa9urgJugMLn8Z3Z/6CeyRQ== + "@rollup/rollup-linux-riscv64-gnu@4.21.0": version "4.21.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.0.tgz#a2eab4346fbe5909165ce99adb935ba30c9fb444" integrity sha512-H1eRaCwd5E8eS8leiS+o/NqMdljkcb1d6r2h4fKSsCXQilLKArq6WS7XBLDu80Yz+nMqHVFDquwcVrQmGr28rg== +"@rollup/rollup-linux-riscv64-gnu@4.21.1": + version "4.21.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.1.tgz#385d76a088c27db8054d9f3f28d64d89294f838e" + integrity sha512-BggMndzI7Tlv4/abrgLwa/dxNEMn2gC61DCLrTzw8LkpSKel4o+O+gtjbnkevZ18SKkeN3ihRGPuBxjaetWzWg== + "@rollup/rollup-linux-riscv64-gnu@4.9.6": version "4.9.6" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.6.tgz#160510e63f4b12618af4013bddf1761cf9fc9880" @@ -2751,11 +2823,21 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.0.tgz#0bc49a79db4345d78d757bb1b05e73a1b42fa5c3" integrity sha512-zJ4hA+3b5tu8u7L58CCSI0A9N1vkfwPhWd/puGXwtZlsB5bTkwDNW/+JCU84+3QYmKpLi+XvHdmrlwUwDA6kqw== +"@rollup/rollup-linux-s390x-gnu@4.21.1": + version "4.21.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.1.tgz#daa2b62a6e6f737ebef6700a12a93c9764e18583" + integrity sha512-z/9rtlGd/OMv+gb1mNSjElasMf9yXusAxnRDrBaYB+eS1shFm6/4/xDH1SAISO5729fFKUkJ88TkGPRUh8WSAA== + "@rollup/rollup-linux-x64-gnu@4.21.0": version "4.21.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.0.tgz#4fd36a6a41f3406d8693321b13d4f9b7658dd4b9" integrity sha512-e2hrvElFIh6kW/UNBQK/kzqMNY5mO+67YtEh9OA65RM5IJXYTWiXjX6fjIiPaqOkBthYF1EqgiZ6OXKcQsM0hg== +"@rollup/rollup-linux-x64-gnu@4.21.1": + version "4.21.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.1.tgz#790ae96118cc892464e9f10da358c0c8a6b9acdd" + integrity sha512-kXQVcWqDcDKw0S2E0TmhlTLlUgAmMVqPrJZR+KpH/1ZaZhLSl23GZpQVmawBQGVhyP5WXIsIQ/zqbDBBYmxm5w== + "@rollup/rollup-linux-x64-gnu@4.9.6": version "4.9.6" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.6.tgz#5ac5d068ce0726bd0a96ca260d5bd93721c0cb98" @@ -2766,6 +2848,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.0.tgz#10ebb13bd4469cbad1a5d9b073bd27ec8a886200" integrity sha512-1vvmgDdUSebVGXWX2lIcgRebqfQSff0hMEkLJyakQ9JQUbLDkEaMsPTLOmyccyC6IJ/l3FZuJbmrBw/u0A0uCQ== +"@rollup/rollup-linux-x64-musl@4.21.1": + version "4.21.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.1.tgz#d613147f7ac15fafe2a0b6249e8484e161ca2847" + integrity sha512-CbFv/WMQsSdl+bpX6rVbzR4kAjSSBuDgCqb1l4J68UYsQNalz5wOqLGYj4ZI0thGpyX5kc+LLZ9CL+kpqDovZA== + "@rollup/rollup-linux-x64-musl@4.9.6": version "4.9.6" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.6.tgz#bafa759ab43e8eab9edf242a8259ffb4f2a57a5d" @@ -2776,6 +2863,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.0.tgz#2fef1a90f1402258ef915ae5a94cc91a5a1d5bfc" integrity sha512-s5oFkZ/hFcrlAyBTONFY1TWndfyre1wOMwU+6KCpm/iatybvrRgmZVM+vCFwxmC5ZhdlgfE0N4XorsDpi7/4XQ== +"@rollup/rollup-win32-arm64-msvc@4.21.1": + version "4.21.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.1.tgz#18349db8250559a5460d59eb3575f9781be4ab98" + integrity sha512-3Q3brDgA86gHXWHklrwdREKIrIbxC0ZgU8lwpj0eEKGBQH+31uPqr0P2v11pn0tSIxHvcdOWxa4j+YvLNx1i6g== + "@rollup/rollup-win32-arm64-msvc@4.9.6": version "4.9.6" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.6.tgz#1cc3416682e5a20d8f088f26657e6e47f8db468e" @@ -2786,6 +2878,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.0.tgz#a18ad47a95c5f264defb60acdd8c27569f816fc1" integrity sha512-G9+TEqRnAA6nbpqyUqgTiopmnfgnMkR3kMukFBDsiyy23LZvUCpiUwjTRx6ezYCjJODXrh52rBR9oXvm+Fp5wg== +"@rollup/rollup-win32-ia32-msvc@4.21.1": + version "4.21.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.1.tgz#199648b68271f7ab9d023f5c077725d51d12d466" + integrity sha512-tNg+jJcKR3Uwe4L0/wY3Ro0H+u3nrb04+tcq1GSYzBEmKLeOQF2emk1whxlzNqb6MMrQ2JOcQEpuuiPLyRcSIw== + "@rollup/rollup-win32-ia32-msvc@4.9.6": version "4.9.6" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.6.tgz#7d2251e1aa5e8a1e47c86891fe4547a939503461" @@ -2796,6 +2893,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.0.tgz#20c09cf44dcb082140cc7f439dd679fe4bba3375" integrity sha512-2jsCDZwtQvRhejHLfZ1JY6w6kEuEtfF9nzYsZxzSlNVKDX+DpsDJ+Rbjkm74nvg2rdx0gwBS+IMdvwJuq3S9pQ== +"@rollup/rollup-win32-x64-msvc@4.21.1": + version "4.21.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.1.tgz#4d3ec02dbf280c20bfeac7e50cd5669b66f9108f" + integrity sha512-xGiIH95H1zU7naUyTKEyOA/I0aexNMUdO9qRv0bLKN3qu25bBdrxZHqA3PTJ24YNN/GdMzG4xkDcd/GvjuhfLg== + "@rollup/rollup-win32-x64-msvc@4.9.6": version "4.9.6" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.6.tgz#2c1fb69e02a3f1506f52698cfdc3a8b6386df9a6" @@ -2883,11 +2985,6 @@ dependencies: "@sentry/types" "7.100.1" -"@sinclair/typebox@^0.27.8": - version "0.27.8" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" - integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== - "@sindresorhus/merge-streams@^2.1.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz#719df7fb41766bc143369eaa0dd56d8dc87c9958" @@ -4381,81 +4478,87 @@ dependencies: "@swc/core" "^1.3.107" -"@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== +"@vitest/coverage-v8@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz#411961ce4fd1177a32b4dd74ab576ed3b859155e" + integrity sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg== dependencies: - "@ampproject/remapping" "^2.2.1" + "@ampproject/remapping" "^2.3.0" "@bcoe/v8-coverage" "^0.2.3" - debug "^4.3.4" + debug "^4.3.5" istanbul-lib-coverage "^3.2.2" istanbul-lib-report "^3.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" + istanbul-lib-source-maps "^5.0.6" + istanbul-reports "^3.1.7" + magic-string "^0.30.10" + magicast "^0.3.4" + std-env "^3.7.0" + test-exclude "^7.0.1" + tinyrainbow "^1.2.0" + +"@vitest/expect@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-2.0.5.tgz#f3745a6a2c18acbea4d39f5935e913f40d26fa86" + integrity sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA== + dependencies: + "@vitest/spy" "2.0.5" + "@vitest/utils" "2.0.5" + chai "^5.1.1" + tinyrainbow "^1.2.0" -"@vitest/expect@1.6.0": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-1.6.0.tgz#0b3ba0914f738508464983f4d811bc122b51fb30" - integrity sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ== +"@vitest/pretty-format@2.0.5", "@vitest/pretty-format@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-2.0.5.tgz#91d2e6d3a7235c742e1a6cc50e7786e2f2979b1e" + integrity sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ== dependencies: - "@vitest/spy" "1.6.0" - "@vitest/utils" "1.6.0" - chai "^4.3.10" + tinyrainbow "^1.2.0" -"@vitest/runner@1.6.0": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-1.6.0.tgz#a6de49a96cb33b0e3ba0d9064a3e8d6ce2f08825" - integrity sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg== +"@vitest/runner@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-2.0.5.tgz#89197e712bb93513537d6876995a4843392b2a84" + integrity sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig== dependencies: - "@vitest/utils" "1.6.0" - p-limit "^5.0.0" - pathe "^1.1.1" + "@vitest/utils" "2.0.5" + pathe "^1.1.2" -"@vitest/snapshot@1.6.0": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-1.6.0.tgz#deb7e4498a5299c1198136f56e6e0f692e6af470" - integrity sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ== +"@vitest/snapshot@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-2.0.5.tgz#a2346bc5013b73c44670c277c430e0334690a162" + integrity sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew== dependencies: - magic-string "^0.30.5" - pathe "^1.1.1" - pretty-format "^29.7.0" + "@vitest/pretty-format" "2.0.5" + magic-string "^0.30.10" + pathe "^1.1.2" -"@vitest/spy@1.6.0": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-1.6.0.tgz#362cbd42ccdb03f1613798fde99799649516906d" - integrity sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw== +"@vitest/spy@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-2.0.5.tgz#590fc07df84a78b8e9dd976ec2090920084a2b9f" + integrity sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA== dependencies: - tinyspy "^2.2.0" + tinyspy "^3.0.0" -"@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== +"@vitest/ui@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@vitest/ui/-/ui-2.0.5.tgz#cfae5f6c7a1cc8cd1be87c88153215cb60a2cc0d" + integrity sha512-m+ZpVt/PVi/nbeRKEjdiYeoh0aOfI9zr3Ria9LO7V2PlMETtAXJS3uETEZkc8Be2oOl8mhd7Ew+5SRBXRYncNw== dependencies: - "@vitest/utils" "1.6.0" + "@vitest/utils" "2.0.5" fast-glob "^3.3.2" - fflate "^0.8.1" - flatted "^3.2.9" - pathe "^1.1.1" - picocolors "^1.0.0" + fflate "^0.8.2" + flatted "^3.3.1" + pathe "^1.1.2" sirv "^2.0.4" + tinyrainbow "^1.2.0" -"@vitest/utils@1.6.0": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-1.6.0.tgz#5c5675ca7d6f546a7b4337de9ae882e6c57896a1" - integrity sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw== +"@vitest/utils@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-2.0.5.tgz#6f8307a4b6bc6ceb9270007f73c67c915944e926" + integrity sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ== dependencies: - diff-sequences "^29.6.3" + "@vitest/pretty-format" "2.0.5" estree-walker "^3.0.3" - loupe "^2.3.7" - pretty-format "^29.7.0" + loupe "^3.1.1" + tinyrainbow "^1.2.0" "@xterm/xterm@^5.5.0": version "5.5.0" @@ -4511,7 +4614,7 @@ acorn-walk@^7.2.0: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== -acorn-walk@^8.1.1, acorn-walk@^8.3.2: +acorn-walk@^8.1.1: version "8.3.2" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.2.tgz#7703af9415f1b6db9315d6895503862e231d34aa" integrity sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A== @@ -4815,10 +4918,10 @@ assert@^2.0.0: object.assign "^4.1.4" util "^0.12.5" -assertion-error@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" - integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== ast-types-flow@^0.0.8: version "0.0.8" @@ -5302,18 +5405,16 @@ chai-string@^1.5.0: resolved "https://registry.yarnpkg.com/chai-string/-/chai-string-1.5.0.tgz#0bdb2d8a5f1dbe90bc78ec493c1c1c180dd4d3d2" integrity sha512-sydDC3S3pNAQMYwJrs6dQX0oBQ6KfIPuOZ78n7rocW0eJJlsHPh2t3kwW7xfwYA/1Bf6/arGtSUo16rxR2JFlw== -chai@^4.3.10: - version "4.4.1" - resolved "https://registry.yarnpkg.com/chai/-/chai-4.4.1.tgz#3603fa6eba35425b0f2ac91a009fe924106e50d1" - integrity sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g== +chai@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/chai/-/chai-5.1.1.tgz#f035d9792a22b481ead1c65908d14bb62ec1c82c" + integrity sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA== dependencies: - assertion-error "^1.1.0" - check-error "^1.0.3" - deep-eql "^4.1.3" - get-func-name "^2.0.2" - loupe "^2.3.6" - pathval "^1.1.1" - type-detect "^4.0.8" + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" chalk-template@0.4.0: version "0.4.0" @@ -5327,7 +5428,7 @@ chalk@5.0.1: resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.0.1.tgz#ca57d71e82bb534a296df63bbacc4a1c22b2a4b6" integrity sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w== -chalk@^2.1.0, chalk@^2.4.1, chalk@^2.4.2: +chalk@^2.1.0, chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -5405,12 +5506,10 @@ chartjs-color@^2.1.0: chartjs-color-string "^0.6.0" color-convert "^1.9.3" -check-error@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.3.tgz#a6502e4312a7ee969f646e83bb3ddd56281bd694" - integrity sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg== - dependencies: - get-func-name "^2.0.2" +check-error@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" + integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== check-more-types@^2.24.0: version "2.24.0" @@ -6144,12 +6243,10 @@ decode-named-character-reference@^1.0.0: dependencies: character-entities "^2.0.0" -deep-eql@^4.1.3: - version "4.1.3" - resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.3.tgz#7c7775513092f7df98d8df9996dd085eb668cc6d" - integrity sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw== - dependencies: - type-detect "^4.0.0" +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== deep-extend@^0.6.0: version "0.6.0" @@ -6243,11 +6340,6 @@ devlop@^1.0.0, devlop@^1.1.0: dependencies: dequal "^2.0.0" -diff-sequences@^29.6.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" - integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== - diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -6524,7 +6616,7 @@ esbuild-register@^3.5.0: dependencies: debug "^4.3.4" -"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0": +"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0", esbuild@^0.21.3: version "0.21.5" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== @@ -7307,7 +7399,7 @@ fflate@^0.4.8: resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae" integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA== -fflate@^0.8.1: +fflate@^0.8.2: version "0.8.2" resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.2.tgz#fc8631f5347812ad6028bbe4a2308b2792aa1dea" integrity sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A== @@ -7454,6 +7546,11 @@ flatted@^3.2.9: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.9.tgz#7eb4c67ca1ba34232ca9d2d93e9886e611ad7daf" integrity sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ== +flatted@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a" + integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw== + flow-parser@0.*: version "0.228.0" resolved "https://registry.yarnpkg.com/flow-parser/-/flow-parser-0.228.0.tgz#0b801507c8cf44257338596b49bd0904caea2026" @@ -7547,7 +7644,7 @@ fs-extra@11.1.1: jsonfile "^6.0.1" universalify "^2.0.0" -fs-extra@>=5, fs-extra@^11.1.0: +fs-extra@^11.1.0: version "11.2.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" integrity sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw== @@ -7628,7 +7725,7 @@ get-east-asian-width@^1.0.0: resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz#5e6ebd9baee6fb8b7b6bd505221065f0cd91f64e" integrity sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA== -get-func-name@^2.0.1, get-func-name@^2.0.2: +get-func-name@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41" integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ== @@ -7729,7 +7826,7 @@ glob-promise@^4.2.0: dependencies: "@types/glob" "^7.1.3" -glob@>=7, glob@^10.3.1, glob@^10.3.10: +glob@^10.3.1, glob@^10.3.10: version "10.3.10" resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.10.tgz#0351ebb809fd187fe421ab96af83d3a70715df4b" integrity sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g== @@ -7740,7 +7837,7 @@ glob@>=7, glob@^10.3.1, glob@^10.3.10: minipass "^5.0.0 || ^6.0.2 || ^7.0.0" path-scurry "^1.10.1" -glob@^10.4.2: +glob@^10.4.1, glob@^10.4.2: version "10.4.5" resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== @@ -7752,7 +7849,7 @@ glob@^10.4.2: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" -glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.0: +glob@^7.1.3, glob@^7.1.6, glob@^7.2.0: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -7833,7 +7930,7 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" -graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4: +graceful-fs@^4.1.11, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -8620,19 +8717,19 @@ 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@^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== +istanbul-lib-source-maps@^5.0.6: + version "5.0.6" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz#acaef948df7747c8eb5fbf1265cb980f6353a441" + integrity sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A== dependencies: "@jridgewell/trace-mapping" "^0.3.23" debug "^4.1.1" istanbul-lib-coverage "^3.0.0" -istanbul-reports@^3.1.6: - version "3.1.6" - resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.6.tgz#2544bcab4768154281a2f0870471902704ccaa1a" - integrity sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg== +istanbul-reports@^3.1.7: + version "3.1.7" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.7.tgz#daed12b9e1dca518e15c056e1e537e741280fa0b" + integrity sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g== dependencies: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" @@ -8667,11 +8764,6 @@ joycon@^3.1.1: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-tokens@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.0.tgz#0f893996d6f3ed46df7f0a3b12a03f5fd84223c1" - integrity sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ== - js-yaml@^3.13.1: version "3.14.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" @@ -8810,11 +8902,6 @@ json5@^2.2.2, json5@^2.2.3: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== -jsonc-parser@^3.2.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.2.1.tgz#031904571ccf929d7670ee8c547545081cb37f1a" - integrity sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA== - jsonfile@^6.0.1: version "6.1.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" @@ -9037,29 +9124,11 @@ listr2@~8.2.4: rfdc "^1.4.1" wrap-ansi "^9.0.0" -load-json-file@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b" - integrity sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw== - dependencies: - graceful-fs "^4.1.2" - parse-json "^4.0.0" - pify "^3.0.0" - strip-bom "^3.0.0" - load-tsconfig@^0.2.3: version "0.2.5" resolved "https://registry.yarnpkg.com/load-tsconfig/-/load-tsconfig-0.2.5.tgz#453b8cd8961bfb912dea77eb6c168fe8cca3d3a1" integrity sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg== -local-pkg@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.5.0.tgz#093d25a346bae59a99f80e75f6e9d36d7e8c925c" - integrity sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg== - dependencies: - mlly "^1.4.2" - pkg-types "^1.0.3" - locate-path@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" @@ -9193,10 +9262,10 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3 dependencies: js-tokens "^3.0.0 || ^4.0.0" -loupe@^2.3.6, loupe@^2.3.7: - version "2.3.7" - resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.7.tgz#6e69b7d4db7d3ab436328013d37d1c8c3540c697" - integrity sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA== +loupe@^3.1.0, loupe@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.1.1.tgz#71d038d59007d890e3247c5db97c1ec5a92edc54" + integrity sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw== dependencies: get-func-name "^2.0.1" @@ -9246,21 +9315,28 @@ magic-string@^0.27.0: dependencies: "@jridgewell/sourcemap-codec" "^1.4.13" -magic-string@^0.30.0, magic-string@^0.30.5: +magic-string@^0.30.0: version "0.30.7" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.7.tgz#0cecd0527d473298679da95a2d7aeb8c64048505" integrity sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA== dependencies: "@jridgewell/sourcemap-codec" "^1.4.15" -magicast@^0.3.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.3.3.tgz#a15760f982deec9dabc5f314e318d7c6bddcb27b" - integrity sha512-ZbrP1Qxnpoes8sz47AM0z08U+jW6TyRgZzcWy3Ma3vDhJttwMwAFDMMQFobwdBxByBD46JYmxRzeF7w2+wJEuw== +magic-string@^0.30.10: + version "0.30.11" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.11.tgz#301a6f93b3e8c2cb13ac1a7a673492c0dfd12954" + integrity sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A== dependencies: - "@babel/parser" "^7.23.6" - "@babel/types" "^7.23.6" - source-map-js "^1.0.2" + "@jridgewell/sourcemap-codec" "^1.5.0" + +magicast@^0.3.4: + version "0.3.5" + resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.3.5.tgz#8301c3c7d66704a0771eb1bad74274f0ec036739" + integrity sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ== + dependencies: + "@babel/parser" "^7.25.4" + "@babel/types" "^7.25.4" + source-map-js "^1.2.0" make-dir@^2.0.0, make-dir@^2.1.0: version "2.1.0" @@ -9494,11 +9570,6 @@ memoizerific@^1.11.3: dependencies: map-or-similar "^1.5.0" -memorystream@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/memorystream/-/memorystream-0.3.1.tgz#86d7090b30ce455d63fbae12dda51a47ddcaf9b2" - integrity sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw== - merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" @@ -9930,16 +10001,6 @@ mkdirp@^3.0.0: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== -mlly@^1.2.0, mlly@^1.4.2: - version "1.5.0" - resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.5.0.tgz#8428a4617d54cc083d3009030ac79739a0e5447a" - integrity sha512-NPVQvAY1xr1QoVeG0cy8yUYC7FQcOx6evl/RjT1wL5FvzPnzOysoqB/jmx/DhssT2dYa8nxECLAaFI/+gVLhDQ== - dependencies: - acorn "^8.11.3" - pathe "^1.1.2" - pkg-types "^1.0.3" - ufo "^1.3.2" - mocha-junit-reporter@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/mocha-junit-reporter/-/mocha-junit-reporter-2.2.1.tgz#739f5595d0f051d07af9d74e32c416e13a41cde5" @@ -10108,21 +10169,6 @@ notistack@^3.0.1: clsx "^1.1.0" goober "^2.0.33" -npm-run-all@^4.1.5: - version "4.1.5" - resolved "https://registry.yarnpkg.com/npm-run-all/-/npm-run-all-4.1.5.tgz#04476202a15ee0e2e214080861bff12a51d98fba" - integrity sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ== - dependencies: - ansi-styles "^3.2.1" - chalk "^2.4.1" - cross-spawn "^6.0.5" - memorystream "^0.3.1" - minimatch "^3.0.4" - pidtree "^0.3.0" - read-pkg "^3.0.0" - shell-quote "^1.6.1" - string.prototype.padend "^3.0.0" - npm-run-path@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" @@ -10394,13 +10440,6 @@ p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" -p-limit@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-5.0.0.tgz#6946d5b7140b649b7a33a027d89b4c625b3a5985" - integrity sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ== - dependencies: - yocto-queue "^1.0.0" - p-locate@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" @@ -10575,13 +10614,6 @@ path-to-regexp@^6.2.0: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.1.tgz#d54934d6798eb9e5ef14e7af7962c945906918e5" integrity sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw== -path-type@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" - integrity sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg== - dependencies: - pify "^3.0.0" - path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" @@ -10605,15 +10637,15 @@ path@^0.12.7: process "^0.11.1" util "^0.10.3" -pathe@^1.1.0, pathe@^1.1.1, pathe@^1.1.2: +pathe@^1.1.1, pathe@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec" integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ== -pathval@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" - integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== +pathval@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.0.tgz#7e2550b422601d4f6b8e26f1301bc8f15a741a25" + integrity sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA== peggy@^4.0.3: version "4.0.3" @@ -10649,11 +10681,6 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -pidtree@^0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.3.1.tgz#ef09ac2cc0533df1f3250ccf2c4d366b0d12114a" - integrity sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA== - pidtree@~0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.6.0.tgz#90ad7b6d42d5841e69e0a2419ef38f8883aa057c" @@ -10693,15 +10720,6 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -pkg-types@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.0.3.tgz#988b42ab19254c01614d13f4f65a2cfc7880f868" - integrity sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A== - dependencies: - jsonc-parser "^3.2.0" - mlly "^1.2.0" - pathe "^1.1.0" - please-upgrade-node@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942" @@ -10744,16 +10762,14 @@ postcss@^8.4.35: picocolors "^1.0.0" source-map-js "^1.0.2" -postinstall@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/postinstall/-/postinstall-0.6.0.tgz#0b1b3b9bc2f4b2d492601cea77da06154f9aae17" - integrity sha512-N5qnlVE47zgSOG12lztyGeE/uha6Bc3jcNBwIJSar0TfexP5xdUr1mkBKzkZ7prAWBT0/v6nSFVNbw+dkI7R6w== +postcss@^8.4.41: + version "8.4.41" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.41.tgz#d6104d3ba272d882fe18fc07d15dc2da62fa2681" + integrity sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ== dependencies: - fs-extra ">=5" - glob ">=7" - minimist "^1.2.0" - resolve-from ">=4" - resolve-pkg ">=1" + nanoid "^3.3.7" + picocolors "^1.0.1" + source-map-js "^1.2.0" prelude-ls@^1.2.1: version "1.2.1" @@ -10796,15 +10812,6 @@ pretty-format@^27.0.2: ansi-styles "^5.0.0" react-is "^17.0.1" -pretty-format@^29.7.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" - integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== - dependencies: - "@jest/schemas" "^29.6.3" - ansi-styles "^5.0.0" - react-is "^18.0.0" - process@^0.11.1, process@^0.11.10: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" @@ -11117,7 +11124,7 @@ react-is@^17.0.1, react-is@^17.0.2: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== -"react-is@^17.0.1 || ^18.0.0", react-is@^18.0.0, react-is@^18.2.0: +"react-is@^17.0.1 || ^18.0.0", react-is@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== @@ -11266,15 +11273,6 @@ react@^17.0.2: loose-envify "^1.1.0" object-assign "^4.1.1" -read-pkg@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389" - integrity sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA== - dependencies: - load-json-file "^4.0.0" - normalize-package-data "^2.3.2" - path-type "^3.0.0" - read-pkg@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-4.0.1.tgz#963625378f3e1c4d48c85872b5a6ec7d5d093237" @@ -11605,11 +11603,6 @@ reselect@^4.0.0: resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.8.tgz#3f5dc671ea168dccdeb3e141236f69f02eaec524" integrity sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ== -resolve-from@>=4, resolve-from@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" - integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== - resolve-from@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" @@ -11620,18 +11613,16 @@ resolve-from@^4.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + resolve-pathname@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== -resolve-pkg@>=1: - version "2.0.0" - resolved "https://registry.yarnpkg.com/resolve-pkg/-/resolve-pkg-2.0.0.tgz#ac06991418a7623edc119084edc98b0e6bf05a41" - integrity sha512-+1lzwXehGCXSeryaISr6WujZzowloigEofRB+dj75y9RRa/obVcYgbHJd53tdYw8pvZj8GojXaaENws8Ktw/hQ== - dependencies: - resolve-from "^5.0.0" - resolve@^1.10.0, resolve@^1.10.1, resolve@^1.12.0, resolve@^1.14.2, resolve@^1.19.0, resolve@^1.22.1, resolve@^1.22.8: version "1.22.8" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" @@ -11761,6 +11752,31 @@ rollup@^4.2.0: "@rollup/rollup-win32-x64-msvc" "4.9.6" fsevents "~2.3.2" +rollup@^4.20.0: + version "4.21.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.21.1.tgz#65b9b9e9de9a64604fab083fb127f3e9eac2935d" + integrity sha512-ZnYyKvscThhgd3M5+Qt3pmhO4jIRR5RGzaSovB6Q7rGNrK5cUncrtLmcTTJVSdcKXyZjW8X8MB0JMSuH9bcAJg== + dependencies: + "@types/estree" "1.0.5" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.21.1" + "@rollup/rollup-android-arm64" "4.21.1" + "@rollup/rollup-darwin-arm64" "4.21.1" + "@rollup/rollup-darwin-x64" "4.21.1" + "@rollup/rollup-linux-arm-gnueabihf" "4.21.1" + "@rollup/rollup-linux-arm-musleabihf" "4.21.1" + "@rollup/rollup-linux-arm64-gnu" "4.21.1" + "@rollup/rollup-linux-arm64-musl" "4.21.1" + "@rollup/rollup-linux-powerpc64le-gnu" "4.21.1" + "@rollup/rollup-linux-riscv64-gnu" "4.21.1" + "@rollup/rollup-linux-s390x-gnu" "4.21.1" + "@rollup/rollup-linux-x64-gnu" "4.21.1" + "@rollup/rollup-linux-x64-musl" "4.21.1" + "@rollup/rollup-win32-arm64-msvc" "4.21.1" + "@rollup/rollup-win32-ia32-msvc" "4.21.1" + "@rollup/rollup-win32-x64-msvc" "4.21.1" + fsevents "~2.3.2" + rrweb-cssom@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" @@ -12038,11 +12054,6 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -shell-quote@^1.6.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680" - integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA== - side-channel@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.5.tgz#9a84546599b48909fb6af1211708d23b1946221b" @@ -12177,6 +12188,11 @@ 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" @@ -12273,7 +12289,7 @@ statuses@2.0.1, statuses@^2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== -std-env@^3.5.0: +std-env@^3.7.0: version "3.7.0" resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.7.0.tgz#c9f7386ced6ecf13360b6c6c55b8aaa4ef7481d2" integrity sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg== @@ -12416,15 +12432,6 @@ string.prototype.matchall@^4.0.8: set-function-name "^2.0.0" side-channel "^1.0.4" -string.prototype.padend@^3.0.0: - version "3.1.5" - resolved "https://registry.yarnpkg.com/string.prototype.padend/-/string.prototype.padend-3.1.5.tgz#311ef3a4e3c557dd999cdf88fbdde223f2ac0f95" - integrity sha512-DOB27b/2UTTD+4myKUFh+/fXWcu/UDyASIXfg+7VzoCNNGOfWvoyU/x5pvVHr++ztyt/oSYI1BcWBBG/hmlNjA== - dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - string.prototype.trim@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz#f9ac6f8af4bd55ddfa8895e6aea92a96395393bd" @@ -12538,13 +12545,6 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== -strip-literal@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-2.1.0.tgz#6d82ade5e2e74f5c7e8739b6c84692bd65f0bd2a" - integrity sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw== - dependencies: - js-tokens "^9.0.0" - style-dictionary@4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/style-dictionary/-/style-dictionary-4.0.1.tgz#d8347d18874e7dff3f4a6faed0ddcb30c797cff0" @@ -12703,14 +12703,14 @@ tempy@^3.1.0: type-fest "^2.12.2" unique-string "^3.0.0" -test-exclude@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" - integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== +test-exclude@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-7.0.1.tgz#20b3ba4906ac20994e275bbcafd68d510264c2a2" + integrity sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg== dependencies: "@istanbuljs/schema" "^0.1.2" - glob "^7.1.4" - minimatch "^3.0.4" + glob "^10.4.1" + minimatch "^9.0.4" text-segmentation@^1.0.3: version "1.0.3" @@ -12773,25 +12773,30 @@ tiny-warning@^1.0.0, tiny-warning@^1.0.2: resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== -tinybench@^2.5.1: - version "2.6.0" - resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.6.0.tgz#1423284ee22de07c91b3752c048d2764714b341b" - integrity sha512-N8hW3PG/3aOoZAN5V/NSAEDz0ZixDSSt5b/a05iqtpgfLWMSVuCo7w0k2vVvEjdrIoeGqZzweX2WlyioNIHchA== +tinybench@^2.8.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== tinycolor2@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== -tinypool@^0.8.3: - version "0.8.4" - resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.8.4.tgz#e217fe1270d941b39e98c625dcecebb1408c9aa8" - integrity sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ== +tinypool@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.0.1.tgz#c64233c4fac4304e109a64340178760116dbe1fe" + integrity sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA== -tinyspy@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-2.2.1.tgz#117b2342f1f38a0dbdcc73a50a454883adf861d1" - integrity sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A== +tinyrainbow@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-1.2.0.tgz#5c57d2fc0fb3d1afd78465c33ca885d04f02abb5" + integrity sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ== + +tinyspy@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-3.0.0.tgz#cb61644f2713cd84dee184863f4642e06ddf0585" + integrity sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA== tmp@^0.0.33: version "0.0.33" @@ -13000,11 +13005,6 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" -type-detect@^4.0.0, type-detect@^4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" - integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== - type-fest@^0.20.2: version "0.20.2" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" @@ -13097,10 +13097,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@^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== +typescript@^5.5.4: + version "5.5.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" + integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== ua-parser-js@^0.7.30, ua-parser-js@^0.7.33: version "0.7.38" @@ -13409,15 +13409,15 @@ victory-vendor@^36.6.8: d3-time "^3.0.0" d3-timer "^3.0.1" -vite-node@1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-1.6.0.tgz#2c7e61129bfecc759478fa592754fd9704aaba7f" - integrity sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw== +vite-node@2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-2.0.5.tgz#36d909188fc6e3aba3da5fc095b3637d0d18e27b" + integrity sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q== dependencies: cac "^6.7.14" - debug "^4.3.4" - pathe "^1.1.1" - picocolors "^1.0.0" + debug "^4.3.5" + pathe "^1.1.2" + tinyrainbow "^1.2.0" vite "^5.0.0" vite-plugin-svgr@^3.2.0: @@ -13429,7 +13429,7 @@ vite-plugin-svgr@^3.2.0: "@svgr/core" "^8.1.0" "@svgr/plugin-jsx" "^8.1.0" -vite@^5.0.0, vite@^5.1.7: +vite@^5.0.0: version "5.1.7" resolved "https://registry.yarnpkg.com/vite/-/vite-5.1.7.tgz#9f685a2c4c70707fef6d37341b0e809c366da619" integrity sha512-sgnEEFTZYMui/sTlH1/XEnVNHMujOahPLGMxn1+5sIT45Xjng1Ec1K78jRP15dSmVgg5WBin9yO81j3o9OxofA== @@ -13440,31 +13440,41 @@ vite@^5.0.0, vite@^5.1.7: optionalDependencies: fsevents "~2.3.3" -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== - dependencies: - "@vitest/expect" "1.6.0" - "@vitest/runner" "1.6.0" - "@vitest/snapshot" "1.6.0" - "@vitest/spy" "1.6.0" - "@vitest/utils" "1.6.0" - acorn-walk "^8.3.2" - chai "^4.3.10" - debug "^4.3.4" +vite@^5.1.7: + version "5.4.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.2.tgz#8acb6ec4bfab823cdfc1cb2d6c53ed311bc4e47e" + integrity sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA== + dependencies: + esbuild "^0.21.3" + postcss "^8.4.41" + rollup "^4.20.0" + optionalDependencies: + fsevents "~2.3.3" + +vitest@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-2.0.5.tgz#2f15a532704a7181528e399cc5b754c7f335fd62" + integrity sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA== + dependencies: + "@ampproject/remapping" "^2.3.0" + "@vitest/expect" "2.0.5" + "@vitest/pretty-format" "^2.0.5" + "@vitest/runner" "2.0.5" + "@vitest/snapshot" "2.0.5" + "@vitest/spy" "2.0.5" + "@vitest/utils" "2.0.5" + chai "^5.1.1" + debug "^4.3.5" execa "^8.0.1" - local-pkg "^0.5.0" - magic-string "^0.30.5" - pathe "^1.1.1" - picocolors "^1.0.0" - std-env "^3.5.0" - strip-literal "^2.0.0" - tinybench "^2.5.1" - tinypool "^0.8.3" + magic-string "^0.30.10" + pathe "^1.1.2" + std-env "^3.7.0" + tinybench "^2.8.0" + tinypool "^1.0.0" + tinyrainbow "^1.2.0" vite "^5.0.0" - vite-node "1.6.0" - why-is-node-running "^2.2.2" + vite-node "2.0.5" + why-is-node-running "^2.3.0" w3c-xmlserializer@^5.0.0: version "5.0.0" @@ -13628,10 +13638,10 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -why-is-node-running@^2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.2.2.tgz#4185b2b4699117819e7154594271e7e344c9973e" - integrity sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA== +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== dependencies: siginfo "^2.0.0" stackback "0.0.2" @@ -13830,11 +13840,6 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -yocto-queue@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" - integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== - yup@^0.32.9: version "0.32.11" resolved "https://registry.yarnpkg.com/yup/-/yup-0.32.11.tgz#d67fb83eefa4698607982e63f7ca4c5ed3cf18c5" From 2e82f16ad1b62656ad1fd811c18b71cd84b841ea Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Thu, 29 Aug 2024 12:17:11 -0400 Subject: [PATCH 02/67] refactor: [M3-8462] - Upgrade to TanStack Query v5 (#10804) * initial work * finish fixing all tsc errors * fix unit tests * fix cloudpulse types --------- Co-authored-by: Banks Nussman --- packages/manager/package.json | 4 +- .../manager/src/components/TagCell/AddTag.tsx | 9 +- .../src/components/TagsInput/TagsInput.tsx | 4 +- .../Account/ObjectStorageSettings.tsx | 2 +- .../useParentChildAuthentication.tsx | 2 +- .../src/features/Backups/BackupDrawer.tsx | 4 +- .../manager/src/features/Backups/utils.ts | 32 +++---- .../manager/src/features/Betas/BetaSignup.tsx | 2 +- .../UpdateContactInformationForm.tsx | 4 +- .../src/features/CloudPulse/Utils/utils.ts | 2 +- .../DatabaseDetail/AccessControls.tsx | 11 ++- .../RestoreFromBackupDialog.tsx | 9 +- .../DatabaseResize/DatabaseResize.tsx | 19 ++-- .../DatabaseSettingsResetPasswordDialog.tsx | 7 +- .../src/features/Domains/DeleteDomain.tsx | 19 ++-- .../features/Domains/DisableDomainDialog.tsx | 7 +- .../src/features/Domains/DomainsLanding.tsx | 9 +- .../EntityTransfersCreate.tsx | 17 ++-- .../Devices/AddLinodeDrawer.tsx | 5 +- .../Devices/AddNodebalancerDrawer.tsx | 2 +- .../Devices/RemoveDeviceDialog.tsx | 4 +- .../FirewallLanding/FirewallDialog.tsx | 4 +- .../ComplianceUpdateModal.tsx | 4 +- .../Images/ImagesLanding/ImagesLanding.tsx | 12 +-- .../DeleteKubernetesClusterDialog.tsx | 5 +- .../KubeSummaryPanel.tsx | 2 +- .../NodePoolsDisplay/AddNodePoolDrawer.tsx | 6 +- .../NodePoolsDisplay/AutoscalePoolDialog.tsx | 11 ++- .../NodePoolsDisplay/DeleteNodePoolDialog.tsx | 7 +- .../NodePoolsDisplay/RecycleNodeDialog.tsx | 4 +- .../NodePoolsDisplay/ResizeNodePoolDrawer.tsx | 9 +- .../RecycleClusterDialog.tsx | 4 +- .../RecycleNodePoolDialog.tsx | 4 +- .../LinodeBackup/CancelBackupsDialog.tsx | 4 +- .../LinodeBackup/CaptureSnapshot.tsx | 2 +- .../LinodeBackup/EnableBackupsDialog.tsx | 4 +- .../LinodeBackup/RestoreToLinodeDrawer.tsx | 4 +- .../LinodeBackup/ScheduleSettings.tsx | 2 +- .../LinodeConfigs/BootConfigDialog.tsx | 7 +- .../LinodeConfigs/DeleteConfigDialog.tsx | 7 +- .../LinodeConfigs/LinodeConfigDialog.test.tsx | 25 +---- .../LinodeNetworking/AddIPDrawer.tsx | 4 +- .../LinodeNetworking/DeleteIPDialog.tsx | 4 +- .../LinodeNetworking/DeleteRangeDialog.tsx | 4 +- .../LinodeNetworking/EditIPRDNSDrawer.tsx | 4 +- .../LinodeNetworking/EditRangeRDNSDrawer.tsx | 4 +- .../LinodeResize/LinodeResize.tsx | 4 +- .../LinodeSettingsAlertsPanel.tsx | 4 +- .../LinodeSettingsDeletePanel.tsx | 4 +- .../LinodeSettingsLabelPanel.tsx | 4 +- .../LinodeSettingsPasswordPanel.tsx | 4 +- .../LinodeSettings/LinodeWatchdogPanel.tsx | 4 +- .../LinodeStorage/DeleteDiskDialog.tsx | 4 +- .../MutationNotification.tsx | 4 +- .../UpgradeVolumesDialog.tsx | 4 +- .../LinodesLanding/DeleteLinodeDialog.tsx | 6 +- .../Linodes/MigrateLinode/MigrateLinode.tsx | 4 +- .../Linodes/PowerActionsDialogOrDrawer.tsx | 6 +- packages/manager/src/features/Lish/Glish.tsx | 6 +- packages/manager/src/features/Lish/Lish.tsx | 2 +- .../manager/src/features/Lish/Weblish.tsx | 9 +- .../NodeBalancers/NodeBalancerCreate.tsx | 4 +- .../NodeBalancerDeleteDialog.tsx | 4 +- .../NodeBalancerSettings.tsx | 4 +- .../ObjectStorage/BucketDetail/BucketSSL.tsx | 15 +-- .../BucketDetail/CreateFolderDrawer.tsx | 4 +- .../BucketLanding/CreateBucketDrawer.tsx | 4 +- .../BucketLanding/OMC_CreateBucketDrawer.tsx | 4 +- .../PlacementGroupsAssignLinodesDrawer.tsx | 4 +- .../PlacementGroupsDeleteModal.tsx | 2 +- .../PlacementGroupsUnassignModal.tsx | 6 +- .../APITokens/CreateAPITokenDrawer.tsx | 4 +- .../Profile/APITokens/EditAPITokenDrawer.tsx | 7 +- .../Profile/APITokens/RevokeTokenDialog.tsx | 8 +- .../PhoneVerification/PhoneVerification.tsx | 2 +- .../RevokeTrustedDevicesDialog.tsx | 4 +- .../AuthenticationSettings/SMSMessaging.tsx | 4 +- .../SecurityQuestions/SecurityQuestions.tsx | 8 +- .../TwoFactor/DisableTwoFactorDialog.tsx | 4 +- .../Profile/DisplaySettings/TimezoneForm.tsx | 8 +- .../OAuthClients/CreateOAuthClientDrawer.tsx | 7 +- .../OAuthClients/DeleteOAuthClientDialog.tsx | 4 +- .../OAuthClients/EditOAuthClientDrawer.tsx | 11 ++- .../OAuthClients/ResetOAuthClientDialog.tsx | 4 +- .../Profile/SSHKeys/CreateSSHKeyDrawer.tsx | 4 +- .../Profile/SSHKeys/DeleteSSHKeyDialog.tsx | 4 +- .../Profile/SSHKeys/EditSSHKeyDrawer.tsx | 9 +- .../features/Profile/Settings/Settings.tsx | 4 +- .../features/Search/SearchLanding.test.tsx | 11 +-- .../SupportTicketDetail/CloseTicketLink.tsx | 7 +- .../Users/UserDeleteConfirmationDialog.tsx | 4 +- .../src/features/Users/UserPermissions.tsx | 2 +- .../VPCs/VPCDetail/SubnetCreateDrawer.tsx | 7 +- .../VPCs/VPCDetail/SubnetDeleteDialog.tsx | 7 +- .../VPCs/VPCDetail/SubnetEditDrawer.tsx | 8 +- .../VPCs/VPCLanding/VPCDeleteDialog.tsx | 4 +- .../VPCs/VPCLanding/VPCEditDrawer.tsx | 4 +- .../features/Volumes/DeleteVolumeDialog.tsx | 7 +- .../features/Volumes/DetachVolumeDialog.tsx | 7 +- .../features/Volumes/UpgradeVolumeDialog.tsx | 4 +- packages/manager/src/hooks/useCreateVPC.ts | 2 +- packages/manager/src/index.tsx | 5 +- .../manager/src/queries/account/account.ts | 13 +-- packages/manager/src/queries/account/betas.ts | 9 +- .../manager/src/queries/account/billing.ts | 6 +- .../manager/src/queries/account/logins.ts | 4 +- .../src/queries/account/maintenance.ts | 4 +- packages/manager/src/queries/account/oauth.ts | 9 +- .../manager/src/queries/account/queries.ts | 2 +- packages/manager/src/queries/account/users.ts | 12 ++- packages/manager/src/queries/base.ts | 8 +- packages/manager/src/queries/betas.ts | 4 +- .../src/queries/cloudpulse/customfilters.ts | 2 +- .../manager/src/queries/cloudpulse/metrics.ts | 51 +++++----- .../src/queries/cloudpulse/services.ts | 4 +- .../src/queries/databases/databases.ts | 9 +- packages/manager/src/queries/domains.ts | 9 +- .../manager/src/queries/entityTransfers.ts | 34 +++---- packages/manager/src/queries/events/events.ts | 94 +++++++++---------- packages/manager/src/queries/firewalls.ts | 13 ++- packages/manager/src/queries/images.ts | 11 ++- packages/manager/src/queries/kubernetes.ts | 11 ++- .../manager/src/queries/linodes/linodes.ts | 12 ++- packages/manager/src/queries/nodebalancers.ts | 9 +- .../src/queries/object-storage/queries.ts | 6 +- .../manager/src/queries/placementGroups.ts | 9 +- .../manager/src/queries/profile/profile.ts | 16 ++-- .../manager/src/queries/profile/tokens.ts | 32 ++++--- packages/manager/src/queries/stackscripts.ts | 3 +- .../src/queries/statusPage/statusPage.ts | 2 +- packages/manager/src/queries/support.ts | 6 +- packages/manager/src/queries/tags.ts | 28 +++--- packages/manager/src/queries/vlans.ts | 4 +- .../manager/src/queries/volumes/volumes.ts | 8 +- packages/manager/src/queries/vpcs/vpcs.ts | 15 ++- yarn.lock | 70 ++++---------- 136 files changed, 600 insertions(+), 545 deletions(-) diff --git a/packages/manager/package.json b/packages/manager/package.json index 875cde6d5b1..0610dbe4518 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -27,8 +27,8 @@ "@paypal/react-paypal-js": "^7.8.3", "@reach/tabs": "^0.10.5", "@sentry/react": "^7.57.0", - "@tanstack/react-query": "4.36.1", - "@tanstack/react-query-devtools": "4.36.1", + "@tanstack/react-query": "5.51.24", + "@tanstack/react-query-devtools": "5.51.24", "algoliasearch": "^4.14.3", "axios": "~1.7.4", "braintree-web": "^3.92.2", diff --git a/packages/manager/src/components/TagCell/AddTag.tsx b/packages/manager/src/components/TagCell/AddTag.tsx index 334a7fc76ef..266f8b10816 100644 --- a/packages/manager/src/components/TagCell/AddTag.tsx +++ b/packages/manager/src/components/TagCell/AddTag.tsx @@ -2,7 +2,7 @@ import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; import { useProfile } from 'src/queries/profile/profile'; -import { updateTagsSuggestionsData, useTagSuggestions } from 'src/queries/tags'; +import { updateTagsSuggestionsData, useAllTagsQuery } from 'src/queries/tags'; import { Autocomplete } from '../Autocomplete/Autocomplete'; @@ -17,10 +17,9 @@ export const AddTag = (props: AddTagProps) => { const queryClient = useQueryClient(); const { data: profile } = useProfile(); - const { - data: accountTags, - isFetching: accountTagsLoading, - } = useTagSuggestions(!profile?.restricted); + const { data: accountTags, isFetching: accountTagsLoading } = useAllTagsQuery( + !profile?.restricted + ); // @todo should we toast for this? If we swallow the error the only // thing we lose is preexisting tabs as options; the add tag flow // should still work. diff --git a/packages/manager/src/components/TagsInput/TagsInput.tsx b/packages/manager/src/components/TagsInput/TagsInput.tsx index 49aa48fe435..c88260181a8 100644 --- a/packages/manager/src/components/TagsInput/TagsInput.tsx +++ b/packages/manager/src/components/TagsInput/TagsInput.tsx @@ -7,7 +7,7 @@ import * as React from 'react'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Chip } from 'src/components/Chip'; import { useProfile } from 'src/queries/profile/profile'; -import { updateTagsSuggestionsData, useTagSuggestions } from 'src/queries/tags'; +import { updateTagsSuggestionsData, useAllTagsQuery } from 'src/queries/tags'; import { getErrorMap } from 'src/utilities/errorUtils'; export interface Tag { @@ -70,7 +70,7 @@ export const TagsInput = (props: TagsInputProps) => { const [errors, setErrors] = React.useState([]); const { data: profile } = useProfile(); - const { data: accountTags, error: accountTagsError } = useTagSuggestions( + const { data: accountTags, error: accountTagsError } = useAllTagsQuery( !profile?.restricted ); diff --git a/packages/manager/src/features/Account/ObjectStorageSettings.tsx b/packages/manager/src/features/Account/ObjectStorageSettings.tsx index fe30d15cf50..316847725d7 100644 --- a/packages/manager/src/features/Account/ObjectStorageSettings.tsx +++ b/packages/manager/src/features/Account/ObjectStorageSettings.tsx @@ -20,7 +20,7 @@ export const ObjectStorageSettings = () => { const { error, - isLoading: isCancelLoading, + isPending: isCancelLoading, mutateAsync: cancelObjectStorage, reset, } = useCancelObjectStorageMutation(); diff --git a/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx b/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx index 7fdd87b63e7..5924c18ec8c 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx @@ -20,7 +20,7 @@ export const useParentChildAuthentication = () => { const { error: createTokenError, - isLoading: createTokenLoading, + isPending: createTokenLoading, mutateAsync: createProxyToken, } = useCreateChildAccountPersonalAccessTokenMutation(); diff --git a/packages/manager/src/features/Backups/BackupDrawer.tsx b/packages/manager/src/features/Backups/BackupDrawer.tsx index 55cf416029c..7df3711a553 100644 --- a/packages/manager/src/features/Backups/BackupDrawer.tsx +++ b/packages/manager/src/features/Backups/BackupDrawer.tsx @@ -60,7 +60,7 @@ export const BackupDrawer = (props: Props) => { const { error: updateAccountSettingsError, - isLoading: isUpdatingAccountSettings, + isPending: isUpdatingAccountSettings, mutateAsync: updateAccountSettings, } = useMutateAccountSettings(); @@ -70,7 +70,7 @@ export const BackupDrawer = (props: Props) => { const { data: enableBackupsResult, - isLoading: isEnablingBackups, + isPending: isEnablingBackups, mutateAsync: enableBackups, } = useEnableBackupsOnLinodesMutation(); diff --git a/packages/manager/src/features/Backups/utils.ts b/packages/manager/src/features/Backups/utils.ts index 156a604d1b6..85193d82625 100644 --- a/packages/manager/src/features/Backups/utils.ts +++ b/packages/manager/src/features/Backups/utils.ts @@ -21,28 +21,26 @@ export const useEnableBackupsOnLinodesMutation = () => { (EnableBackupsFufilledResult | EnableBackupsRejectedResult)[], unknown, Linode[] - >( - async (linodes) => { + >({ + mutationFn: async (linodes) => { const data = await Promise.allSettled( linodes.map((linode) => enableBackups(linode.id)) ); return linodes.map((linode, idx) => ({ linode, ...data[idx] })); }, - { - onSuccess(_, variables) { - queryClient.invalidateQueries(linodeQueries.linodes); - for (const linode of variables) { - queryClient.invalidateQueries({ - exact: true, - queryKey: linodeQueries.linode(linode.id).queryKey, - }); - queryClient.invalidateQueries({ - queryKey: linodeQueries.linode(linode.id)._ctx.backups.queryKey, - }); - } - }, - } - ); + onSuccess(_, variables) { + queryClient.invalidateQueries(linodeQueries.linodes); + for (const linode of variables) { + queryClient.invalidateQueries({ + exact: true, + queryKey: linodeQueries.linode(linode.id).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: linodeQueries.linode(linode.id)._ctx.backups.queryKey, + }); + } + }, + }); }; interface FailureNotificationProps { diff --git a/packages/manager/src/features/Betas/BetaSignup.tsx b/packages/manager/src/features/Betas/BetaSignup.tsx index 9e6273a9040..448d6dad3fa 100644 --- a/packages/manager/src/features/Betas/BetaSignup.tsx +++ b/packages/manager/src/features/Betas/BetaSignup.tsx @@ -138,7 +138,7 @@ EAP and the MSA, this EAP shall be deemed controlling only with respect to its e <> - {isLoading ? ( + {isLoading || !beta ? ( ) : ( 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 76b13950a9d..0bab8d585f8 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx @@ -30,7 +30,7 @@ const excludedUSRegions = ['Micronesia', 'Marshall Islands', 'Palau']; const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { const { data: account } = useAccount(); - const { error, isLoading, mutateAsync } = useMutateAccount(); + const { error, isPending, mutateAsync } = useMutateAccount(); const { data: notifications, refetch } = useNotificationsQuery(); const { classes } = useStyles(); const emailRef = React.useRef(); @@ -391,7 +391,7 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { 'data-testid': 'save-contact-info', disabled: isReadOnly, label: 'Save Changes', - loading: isLoading, + loading: isPending, type: 'submit', }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 2004ba5af41..7c168f4e487 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -141,7 +141,7 @@ export const getAllDashboards = ( let error = ''; let isLoading = false; const data: Dashboard[] = queryResults - .filter((queryResult: UseQueryResult, index) => { + .filter((queryResult, index) => { if (queryResult.isError) { error += serviceTypes[index] + ' ,'; } diff --git a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx index b60a58ede7f..1f6f78dad16 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx @@ -1,6 +1,3 @@ -import { Database } from '@linode/api-v4/lib/databases'; -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'; @@ -15,10 +12,14 @@ import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { Typography } from 'src/components/Typography'; import { useDatabaseMutation } from 'src/queries/databases/databases'; -import { ExtendedIP, stringToExtendedIP } from 'src/utilities/ipUtils'; +import { stringToExtendedIP } from 'src/utilities/ipUtils'; import AddAccessControlDrawer from './AddAccessControlDrawer'; +import type { APIError, Database } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; +import type { ExtendedIP } from 'src/utilities/ipUtils'; + const useStyles = makeStyles()((theme: Theme) => ({ addAccessControlBtn: { minWidth: 225, @@ -107,7 +108,7 @@ export const AccessControls = (props: Props) => { const [extendedIPs, setExtendedIPs] = React.useState([]); const { - isLoading: databaseUpdating, + isPending: databaseUpdating, mutateAsync: updateDatabase, } = useDatabaseMutation(engine, id); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/RestoreFromBackupDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/RestoreFromBackupDialog.tsx index f4ea747f158..fb6b690e69f 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/RestoreFromBackupDialog.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/RestoreFromBackupDialog.tsx @@ -1,9 +1,7 @@ -import { Database, DatabaseBackup } from '@linode/api-v4/lib/databases'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { DialogProps } from 'src/components/Dialog/Dialog'; import { Notice } from 'src/components/Notice/Notice'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { Typography } from 'src/components/Typography'; @@ -12,6 +10,9 @@ import { useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { formatDate } from 'src/utilities/formatDate'; +import type { Database, DatabaseBackup } from '@linode/api-v4'; +import type { DialogProps } from 'src/components/Dialog/Dialog'; + interface Props extends Omit { backup: DatabaseBackup; database: Database; @@ -27,7 +28,7 @@ export const RestoreFromBackupDialog: React.FC = (props) => { const { error, - isLoading, + isPending, mutateAsync: restore, } = useRestoreFromBackupMutation(database.engine, database.id, backup.id); @@ -54,7 +55,7 @@ export const RestoreFromBackupDialog: React.FC = (props) => { timezone: profile?.timezone, })}`} label={'Database Label'} - loading={isLoading} + loading={isPending} onClick={handleRestoreDatabase} onClose={onClose} open={open} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx index faa30e72f40..6c46e2a1dea 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx @@ -1,10 +1,3 @@ -import { - Database, - DatabaseClusterSizeObject, - DatabasePriceObject, - DatabaseType, - Engine, -} from '@linode/api-v4/lib/databases/types'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; @@ -16,7 +9,6 @@ import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { Typography } from 'src/components/Typography'; -import { PlanSelectionType } from 'src/features/components/PlansPanel/types'; import { typeLabelDetails } from 'src/features/Linodes/presentation'; import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; import { useDatabaseMutation } from 'src/queries/databases/databases'; @@ -30,6 +22,15 @@ import { } from './DatabaseResize.style'; import { DatabaseResizeCurrentConfiguration } from './DatabaseResizeCurrentConfiguration'; +import type { + Database, + DatabaseClusterSizeObject, + DatabasePriceObject, + DatabaseType, + Engine, +} from '@linode/api-v4'; +import type { PlanSelectionType } from 'src/features/components/PlansPanel/types'; + interface Props { database: Database; } @@ -57,7 +58,7 @@ export const DatabaseResize = ({ database }: Props) => { const { error: resizeError, - isLoading: submitInProgress, + isPending: submitInProgress, mutateAsync: updateDatabase, } = useDatabaseMutation(database.engine, database.id); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsResetPasswordDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsResetPasswordDialog.tsx index 882142160c5..6866fcc99f5 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsResetPasswordDialog.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsResetPasswordDialog.tsx @@ -1,4 +1,3 @@ -import { Engine } from '@linode/api-v4/lib/databases'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -7,6 +6,8 @@ import { Notice } from 'src/components/Notice/Notice'; import { Typography } from 'src/components/Typography'; import { useDatabaseCredentialsMutation } from 'src/queries/databases/databases'; +import type { Engine } from '@linode/api-v4'; + interface Props { databaseEngine: Engine; databaseID: number; @@ -36,7 +37,7 @@ const renderActions = ( export const DatabaseSettingsResetPasswordDialog: React.FC = (props) => { const { databaseEngine, databaseID, onClose, open } = props; - const { error, isLoading, mutateAsync } = useDatabaseCredentialsMutation( + const { error, isPending, mutateAsync } = useDatabaseCredentialsMutation( databaseEngine, databaseID ); @@ -48,7 +49,7 @@ export const DatabaseSettingsResetPasswordDialog: React.FC = (props) => { return ( { const { enqueueSnackbar } = useSnackbar(); const { - mutateAsync: deleteDomain, error, - isLoading, + isPending, + mutateAsync: deleteDomain, } = useDeleteDomainMutation(domainId); const [open, setOpen] = React.useState(false); @@ -42,18 +43,18 @@ export const DeleteDomain = (props: DeleteDomainProps) => { Delete Domain setOpen(false)} onDelete={onDelete} + open={open} + typeToConfirm /> ); diff --git a/packages/manager/src/features/Domains/DisableDomainDialog.tsx b/packages/manager/src/features/Domains/DisableDomainDialog.tsx index 67a15e2bf05..fd770db6110 100644 --- a/packages/manager/src/features/Domains/DisableDomainDialog.tsx +++ b/packages/manager/src/features/Domains/DisableDomainDialog.tsx @@ -1,4 +1,3 @@ -import { Domain } from '@linode/api-v4/lib/domains'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -6,6 +5,8 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { useUpdateDomainMutation } from 'src/queries/domains'; import { sendDomainStatusChangeEvent } from 'src/utilities/analytics/customEventAnalytics'; +import type { Domain } from '@linode/api-v4'; + interface DisableDomainDialogProps { domain: Domain | undefined; onClose: () => void; @@ -17,7 +18,7 @@ export const DisableDomainDialog = React.memo( const { domain, onClose, open } = props; const { error, - isLoading, + isPending, mutateAsync: updateDomain, reset, } = useUpdateDomainMutation(); @@ -48,7 +49,7 @@ export const DisableDomainDialog = React.memo( { const { error: deleteError, - isLoading: isDeleting, + isPending: isDeleting, mutateAsync: deleteDomain, } = useDeleteDomainMutation(selectedDomain?.id ?? 0); diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx index ff05bf764f6..b021e5fa729 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx @@ -1,8 +1,7 @@ -import { CreateTransferPayload } from '@linode/api-v4/lib/entity-transfers'; import Grid from '@mui/material/Unstable_Grid2'; +import { useQueryClient } from '@tanstack/react-query'; import { curry } from 'ramda'; import * as React from 'react'; -import { QueryClient, useQueryClient } from '@tanstack/react-query'; import { useHistory } from 'react-router-dom'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; @@ -20,15 +19,15 @@ import { import { LinodeTransferTable } from './LinodeTransferTable'; import { TransferCheckoutBar } from './TransferCheckoutBar'; import { TransferHeader } from './TransferHeader'; -import { - TransferableEntity, - defaultTransferState, - transferReducer, -} from './transferReducer'; +import { defaultTransferState, transferReducer } from './transferReducer'; + +import type { TransferableEntity } from './transferReducer'; +import type { CreateTransferPayload } from '@linode/api-v4'; +import type { QueryClient } from '@tanstack/react-query'; export const EntityTransfersCreate = () => { const { push } = useHistory(); - const { error, isLoading, mutateAsync: createTransfer } = useCreateTransfer(); + const { error, isPending, mutateAsync: createTransfer } = useCreateTransfer(); const queryClient = useQueryClient(); /** @@ -115,7 +114,7 @@ export const EntityTransfersCreate = () => { handleSubmit={(payload) => handleCreateTransfer(payload, queryClient) } - isCreating={isLoading} + isCreating={isPending} removeEntities={removeEntitiesFromTransfer} selectedEntities={state} /> diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx index 5efc15e910b..cd4eab3e95d 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx @@ -1,4 +1,3 @@ -import { Linode } from '@linode/api-v4'; import { useTheme } from '@mui/material'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -19,6 +18,8 @@ import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getEntityIdsByPermission } from 'src/utilities/grants'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; +import type { Linode } from '@linode/api-v4'; + interface Props { helperText: string; onClose: () => void; @@ -43,7 +44,7 @@ export const AddLinodeDrawer = (props: Props) => { const theme = useTheme(); const { - isLoading: addDeviceIsLoading, + isPending: addDeviceIsLoading, mutateAsync: addDevice, } = useAddFirewallDeviceMutation(Number(id)); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx index 5667d69ada9..86f799f810a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx @@ -42,7 +42,7 @@ export const AddNodebalancerDrawer = (props: Props) => { const theme = useTheme(); const { - isLoading: addDeviceIsLoading, + isPending: addDeviceIsLoading, mutateAsync: addDevice, } = useAddFirewallDeviceMutation(Number(id)); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx index 9d199ce7d9b..23ebf136f2d 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx @@ -26,7 +26,7 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { const { enqueueSnackbar } = useSnackbar(); const deviceType = device?.entity.type; - const { error, isLoading, mutateAsync } = useRemoveFirewallDeviceMutation( + const { error, isPending, mutateAsync } = useRemoveFirewallDeviceMutation( firewallId, device?.id ?? -1 ); @@ -93,7 +93,7 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { { const { error: updateError, - isLoading: isUpdating, + isPending: isUpdating, mutateAsync: updateFirewall, } = useMutateFirewall(selectedFirewall.id); const { error: deleteError, - isLoading: isDeleting, + isPending: isDeleting, mutateAsync: deleteFirewall, } = useDeleteFirewall(selectedFirewall.id); diff --git a/packages/manager/src/features/GlobalNotifications/ComplianceUpdateModal.tsx b/packages/manager/src/features/GlobalNotifications/ComplianceUpdateModal.tsx index 4154fb918f6..7486085bfd7 100644 --- a/packages/manager/src/features/GlobalNotifications/ComplianceUpdateModal.tsx +++ b/packages/manager/src/features/GlobalNotifications/ComplianceUpdateModal.tsx @@ -20,7 +20,7 @@ export const ComplianceUpdateModal = () => { const complianceModelContext = React.useContext(complianceUpdateContext); const { - isLoading, + isPending, mutateAsync: updateAccountAgreements, } = useMutateAccountAgreements(); @@ -50,7 +50,7 @@ export const ComplianceUpdateModal = () => { primaryButtonProps={{ disabled: !checked, label: 'Agree', - loading: isLoading, + loading: isPending, onClick: handleAgree, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx index 265662eac9e..3766c1d86e8 100644 --- a/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx +++ b/packages/manager/src/features/Images/ImagesLanding/ImagesLanding.tsx @@ -373,8 +373,8 @@ export const ImagesLanding = () => { } if ( - manualImages.results === 0 && - automaticImages.results === 0 && + manualImages?.results === 0 && + automaticImages?.results === 0 && !imageLabelFromParam ) { return ; @@ -489,13 +489,13 @@ export const ImagesLanding = () => { - {manualImages.results === 0 && ( + {manualImages?.results === 0 && ( )} - {manualImages.data.map((manualImage) => ( + {manualImages?.data.map((manualImage) => ( { - {automaticImages.results === 0 && ( + {automaticImages?.results === 0 && ( )} - {automaticImages.data.map((automaticImage) => ( + {automaticImages?.data.map((automaticImage) => ( { const { clusterId, clusterLabel, onClose, open } = props; const { error, - isLoading: isDeleting, + isPending: isDeleting, mutateAsync: deleteCluster, } = useDeleteKubernetesClusterMutation(); const history = useHistory(); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx index abaf0379c01..a3579c98c27 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeSummaryPanel.tsx @@ -120,7 +120,7 @@ export const KubeSummaryPanel = React.memo((props: Props) => { const { error: resetKubeConfigError, - isLoading: isResettingKubeConfig, + isPending: isResettingKubeConfig, mutateAsync: resetKubeConfig, } = useResetKubeConfigMutation(); diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx index e424e4759e7..0c74fcb4f6a 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx @@ -80,7 +80,7 @@ export const AddNodePoolDrawer = (props: Props) => { const { data: types } = useAllTypes(open); const { error, - isLoading, + isPending, mutateAsync: createPool, } = useCreateNodePoolMutation(clusterId); @@ -186,7 +186,7 @@ export const AddNodePoolDrawer = (props: Props) => { hasSelectedRegion={hasSelectedRegion} isPlanPanelDisabled={isPlanPanelDisabled} isSelectedRegionEligibleForPlan={isSelectedRegionEligibleForPlan} - isSubmitting={isLoading} + isSubmitting={isPending} regionsData={regionsData} resetValues={resetDrawer} selectedId={selectedTypeInfo?.planId} @@ -237,7 +237,7 @@ export const AddNodePoolDrawer = (props: Props) => { primaryButtonProps={{ disabled: !selectedTypeInfo || hasInvalidPrice, label: 'Add pool', - loading: isLoading, + loading: isPending, onClick: handleAdd, }} /> diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx index 976c7c7ae12..5827db7f9a9 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AutoscalePoolDialog.tsx @@ -1,11 +1,9 @@ -import { AutoscaleSettings, KubeNodePoolResponse } from '@linode/api-v4'; import { AutoscaleNodePoolSchema } from '@linode/validation/lib/kubernetes.schema'; import Grid from '@mui/material/Unstable_Grid2'; -import { Theme } from '@mui/material/styles'; -import { makeStyles } from 'tss-react/mui'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Button } from 'src/components/Button/Button'; @@ -18,6 +16,9 @@ import { Toggle } from 'src/components/Toggle/Toggle'; import { Typography } from 'src/components/Typography'; import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; +import type { AutoscaleSettings, KubeNodePoolResponse } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; + interface Props { clusterId: number; handleOpenResizeDrawer: (poolId: number) => void; @@ -72,7 +73,7 @@ export const AutoscalePoolDialog = (props: Props) => { const { classes, cx } = useStyles(); const { enqueueSnackbar } = useSnackbar(); - const { error, isLoading, mutateAsync } = useUpdateNodePoolMutation( + const { error, isPending, mutateAsync } = useUpdateNodePoolMutation( clusterId, nodePool?.id ?? -1 ); @@ -126,7 +127,7 @@ export const AutoscalePoolDialog = (props: Props) => { values.max === autoscaler?.max) || Object.keys(errors).length !== 0, label: 'Save Changes', - loading: isLoading || isSubmitting, + loading: isPending || isSubmitting, onClick: () => handleSubmit(), }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/DeleteNodePoolDialog.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/DeleteNodePoolDialog.tsx index 57039d4161d..03252855280 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/DeleteNodePoolDialog.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/DeleteNodePoolDialog.tsx @@ -1,4 +1,3 @@ -import { KubeNodePoolResponse } from '@linode/api-v4'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; @@ -7,6 +6,8 @@ import { Typography } from 'src/components/Typography'; import { useDeleteNodePoolMutation } from 'src/queries/kubernetes'; import { pluralize } from 'src/utilities/pluralize'; +import type { KubeNodePoolResponse } from '@linode/api-v4'; + interface Props { kubernetesClusterId: number; nodePool: KubeNodePoolResponse | undefined; @@ -17,7 +18,7 @@ interface Props { export const DeleteNodePoolDialog = (props: Props) => { const { kubernetesClusterId, nodePool, onClose, open } = props; - const { error, isLoading, mutateAsync } = useDeleteNodePoolMutation( + const { error, isPending, mutateAsync } = useDeleteNodePoolMutation( kubernetesClusterId, nodePool?.id ?? -1 ); @@ -35,7 +36,7 @@ export const DeleteNodePoolDialog = (props: Props) => { primaryButtonProps={{ 'data-testid': 'confirm', label: 'Delete', - loading: isLoading, + loading: isPending, onClick: onDelete, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/RecycleNodeDialog.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/RecycleNodeDialog.tsx index 7d049d5bb7f..29fe3e4fd48 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/RecycleNodeDialog.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/RecycleNodeDialog.tsx @@ -19,7 +19,7 @@ export const RecycleNodeDialog = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); - const { error, isLoading, mutateAsync } = useRecycleNodeMutation( + const { error, isPending, mutateAsync } = useRecycleNodeMutation( clusterId, nodeId ); @@ -36,7 +36,7 @@ export const RecycleNodeDialog = (props: Props) => { primaryButtonProps={{ 'data-testid': 'confirm', label: 'Recycle', - loading: isLoading, + loading: isPending, onClick: onSubmit, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx index f097ae6fd17..3bfa0f3ef1f 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx @@ -1,5 +1,3 @@ -import { KubeNodePoolResponse, Region } from '@linode/api-v4'; -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -22,6 +20,9 @@ import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; import { nodeWarning } from '../../kubeUtils'; import { hasInvalidNodePoolPrice } from './utils'; +import type { KubeNodePoolResponse, Region } from '@linode/api-v4'; +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()((theme: Theme) => ({ helperText: { paddingBottom: `calc(${theme.spacing(2)} + 1px)`, @@ -64,7 +65,7 @@ export const ResizeNodePoolDrawer = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: updateNodePool, } = useUpdateNodePoolMutation(kubernetesClusterId, nodePool?.id ?? -1); @@ -186,7 +187,7 @@ export const ResizeNodePoolDrawer = (props: Props) => { 'data-testid': 'submit', disabled: updatedCount === nodePool.count || hasInvalidPrice, label: 'Save Changes', - loading: isLoading, + loading: isPending, onClick: handleSubmit, }} /> diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/RecycleClusterDialog.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/RecycleClusterDialog.tsx index 9f21a7e5c92..31f999342d2 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/RecycleClusterDialog.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/RecycleClusterDialog.tsx @@ -20,7 +20,7 @@ export const RecycleClusterDialog = (props: Props) => { const { clusterId, onClose, open } = props; const { enqueueSnackbar } = useSnackbar(); - const { error, isLoading, mutateAsync } = useRecycleClusterMutation( + const { error, isPending, mutateAsync } = useRecycleClusterMutation( clusterId ); @@ -38,7 +38,7 @@ export const RecycleClusterDialog = (props: Props) => { primaryButtonProps={{ 'data-testid': 'confirm', label: 'Recycle All Cluster Nodes', - loading: isLoading, + loading: isPending, onClick: onSubmit, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/RecycleNodePoolDialog.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/RecycleNodePoolDialog.tsx index c1c94545378..8deb2c6ecbe 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/RecycleNodePoolDialog.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/RecycleNodePoolDialog.tsx @@ -22,7 +22,7 @@ export const RecycleNodePoolDialog = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); - const { error, isLoading, mutateAsync } = useRecycleNodePoolMutation( + const { error, isPending, mutateAsync } = useRecycleNodePoolMutation( clusterId, nodePoolId ); @@ -41,7 +41,7 @@ export const RecycleNodePoolDialog = (props: Props) => { primaryButtonProps={{ 'data-testid': 'confirm', label: 'Recycle Pool Nodes', - loading: isLoading, + loading: isPending, onClick: onRecycle, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CancelBackupsDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CancelBackupsDialog.tsx index 848f84d4820..1b5990efdee 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CancelBackupsDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CancelBackupsDialog.tsx @@ -20,7 +20,7 @@ export const CancelBackupsDialog = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: cancelBackups, } = useLinodeBackupsCancelMutation(linodeId); @@ -43,7 +43,7 @@ export const CancelBackupsDialog = (props: Props) => { primaryButtonProps={{ 'data-testid': 'confirm-cancel', label: 'Cancel Backups', - loading: isLoading, + loading: isPending, onClick: onCancelBackups, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.tsx index e25355458de..7cc32e768e6 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/CaptureSnapshot.tsx @@ -28,7 +28,7 @@ export const CaptureSnapshot = (props: Props) => { const { error: snapshotError, - isLoading: isSnapshotLoading, + isPending: isSnapshotLoading, mutateAsync: takeSnapshot, reset, } = useLinodeBackupSnapshotMutation(linodeId); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx index 7a3c74ff111..9c650c75a7a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/EnableBackupsDialog.tsx @@ -28,7 +28,7 @@ export const EnableBackupsDialog = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: enableBackups, reset, } = useLinodeBackupsEnableMutation(linodeId ?? -1); @@ -82,7 +82,7 @@ export const EnableBackupsDialog = (props: Props) => { 'data-testid': 'confirm-enable-backups', disabled: hasBackupsMonthlyPriceError, label: 'Enable Backups', - loading: isLoading, + loading: isPending, onClick: handleEnableBackups, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx index 4abf31d5e11..197a2448624 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/RestoreToLinodeDrawer.tsx @@ -47,7 +47,7 @@ export const RestoreToLinodeDrawer = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: restoreBackup, reset: resetMutation, } = useLinodeBackupRestoreMutation(); @@ -155,7 +155,7 @@ export const RestoreToLinodeDrawer = (props: Props) => { primaryButtonProps={{ 'data-testid': 'restore-submit', label: 'Restore', - loading: isLoading, + loading: isPending, type: 'submit', }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.tsx index 504bf41fcc6..bf34e71d935 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeBackup/ScheduleSettings.tsx @@ -32,7 +32,7 @@ export const ScheduleSettings = (props: Props) => { const { error: updateLinodeError, - isLoading: isUpdating, + isPending: isUpdating, mutateAsync: updateLinode, } = useLinodeUpdateMutation(linodeId); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/BootConfigDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/BootConfigDialog.tsx index 59562064701..fc03df168c5 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/BootConfigDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/BootConfigDialog.tsx @@ -1,4 +1,3 @@ -import { Config } from '@linode/api-v4'; import { useSnackbar } from 'notistack'; import React from 'react'; @@ -8,6 +7,8 @@ import { Typography } from 'src/components/Typography'; import { useEventsPollingActions } from 'src/queries/events/events'; import { useRebootLinodeMutation } from 'src/queries/linodes/linodes'; +import type { Config } from '@linode/api-v4'; + interface Props { config: Config | undefined; linodeId: number; @@ -19,7 +20,7 @@ export const BootConfigDialog = (props: Props) => { const { config, linodeId, onClose, open } = props; const { enqueueSnackbar } = useSnackbar(); - const { error, isLoading, mutateAsync } = useRebootLinodeMutation(linodeId); + const { error, isPending, mutateAsync } = useRebootLinodeMutation(linodeId); const { checkForNewEvents } = useEventsPollingActions(); @@ -36,7 +37,7 @@ export const BootConfigDialog = (props: Props) => { { const { config, linodeId, onClose, open } = props; const { enqueueSnackbar } = useSnackbar(); - const { error, isLoading, mutateAsync } = useLinodeConfigDeleteMutation( + const { error, isPending, mutateAsync } = useLinodeConfigDeleteMutation( linodeId, config?.id ?? -1 ); @@ -35,7 +36,7 @@ export const DeleteConfigDialog = (props: Props) => { { onClose: vi.fn(), }; - const { - getAllByPlaceholderText, - findByText, - getByTestId, - rerender, - } = renderWithTheme( + const { findByDisplayValue, rerender } = renderWithTheme( ); @@ -175,17 +168,7 @@ describe('LinodeConfigDialog', () => { /> ); - const loadingTestId = 'circle-progress'; - // Loading state should render - expect(getByTestId(loadingTestId)).toBeInTheDocument(); - - await waitForElementToBeRemoved(getByTestId(loadingTestId)); - - const interfaceSelectMenu = getAllByPlaceholderText('Select an Interface'); - - await userEvent.click(interfaceSelectMenu[0]); - - await findByText('VPC'); - await findByText('Public Internet'); + await findByDisplayValue('VPC'); + await findByDisplayValue('Public Internet'); }); }); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx index 83806a57416..f018743f17f 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx @@ -94,14 +94,14 @@ export const AddIPDrawer = (props: Props) => { const { error: ipv4Error, - isLoading: ipv4Loading, + isPending: ipv4Loading, mutateAsync: allocateIPAddress, reset: resetIPv4, } = useAllocateIPMutation(linodeId); const { error: ipv6Error, - isLoading: ipv6Loading, + isPending: ipv6Loading, mutateAsync: createIPv6Range, reset: resetIPv6, } = useCreateIPv6RangeMutation(); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/DeleteIPDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/DeleteIPDialog.tsx index 16221f0a588..5450a53bfdf 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/DeleteIPDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/DeleteIPDialog.tsx @@ -17,7 +17,7 @@ export const DeleteIPDialog = (props: Props) => { const { address, linodeId, onClose, open } = props; const { enqueueSnackbar } = useSnackbar(); - const { error, isLoading, mutateAsync: removeIP } = useLinodeIPDeleteMutation( + const { error, isPending, mutateAsync: removeIP } = useLinodeIPDeleteMutation( linodeId, address ); @@ -34,7 +34,7 @@ export const DeleteIPDialog = (props: Props) => { { const { error, - isLoading, + isPending, mutateAsync: removeRange, } = useLinodeRemoveRangeMutation(range.range); @@ -37,7 +37,7 @@ export const DeleteRangeDialog = (props: Props) => { { const { error, - isLoading, + isPending, mutateAsync: updateIP, reset, } = useLinodeIPMutation(); @@ -77,7 +77,7 @@ export const EditIPRDNSDrawer = (props: Props) => { primaryButtonProps={{ 'data-testid': 'submit', label: 'Save', - loading: isLoading, + loading: isPending, type: 'submit', }} secondaryButtonProps={{ label: 'cancel', onClick: onClose }} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.tsx index a94ff66ec1d..dc8362c8e72 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.tsx @@ -45,7 +45,7 @@ export const EditRangeRDNSDrawer = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: updateIP, reset, } = useLinodeIPMutation(); @@ -111,7 +111,7 @@ export const EditRangeRDNSDrawer = (props: Props) => { primaryButtonProps={{ 'data-testid': 'submit', label: 'Save', - loading: isLoading, + loading: isPending, type: 'submit', }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx index fd5ac87dd92..a88ad4753bf 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResize.tsx @@ -78,7 +78,7 @@ export const LinodeResize = (props: Props) => { const { error: resizeError, - isLoading, + isPending, mutateAsync: resizeLinode, } = useLinodeResizeMutation(linodeId ?? -1); @@ -326,7 +326,7 @@ export const LinodeResize = (props: Props) => { } buttonType="primary" data-qa-resize - loading={isLoading} + loading={isPending} type="submit" > Resize Linode diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsAlertsPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsAlertsPanel.tsx index ed710c27536..43923be7b1b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsAlertsPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsAlertsPanel.tsx @@ -29,7 +29,7 @@ export const LinodeSettingsAlertsPanel = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: updateLinode, } = useLinodeUpdateMutation(linodeId); @@ -216,7 +216,7 @@ export const LinodeSettingsAlertsPanel = (props: Props) => { 'data-testid': 'alerts-save', disabled: isReadOnly || !formik.dirty, label: 'Save', - loading: isLoading, + loading: isPending, onClick: () => formik.handleSubmit(), }} /> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsDeletePanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsDeletePanel.tsx index ff27b1d8c9b..820b50b27b0 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsDeletePanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsDeletePanel.tsx @@ -22,7 +22,7 @@ export const LinodeSettingsDeletePanel = (props: Props) => { const { data: linode } = useLinodeQuery(linodeId); const { error, - isLoading, + isPending, mutateAsync: deleteLinode, } = useDeleteLinodeMutation(linodeId); @@ -63,7 +63,7 @@ export const LinodeSettingsDeletePanel = (props: Props) => { }} errors={error} label={'Linode Label'} - loading={isLoading} + loading={isPending} onClick={onDelete} onClose={() => setOpen(false)} open={open} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsLabelPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsLabelPanel.tsx index d3a594d4e69..abe4ea91ee2 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsLabelPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsLabelPanel.tsx @@ -27,7 +27,7 @@ export const LinodeSettingsLabelPanel = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: updateLinode, } = useLinodeUpdateMutation(linodeId); @@ -57,7 +57,7 @@ export const LinodeSettingsLabelPanel = (props: Props) => { 'data-testid': 'label-save', disabled: isReadOnly || !formik.dirty, label: 'Save', - loading: isLoading, + loading: isPending, onClick: () => formik.handleSubmit(), }} /> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsPasswordPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsPasswordPanel.tsx index e75d1c93876..b34f58107e8 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsPasswordPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsPasswordPanel.tsx @@ -51,12 +51,12 @@ export const LinodeSettingsPasswordPanel = (props: Props) => { const { error: linodePasswordError, - isLoading: isLinodePasswordLoading, + isPending: isLinodePasswordLoading, mutateAsync: changeLinodePassword, } = useLinodeChangePasswordMutation(linodeId); const { error: diskPasswordError, - isLoading: isDiskPasswordLoading, + isPending: isDiskPasswordLoading, mutateAsync: changeLinodeDiskPassword, } = useLinodeDiskChangePasswordMutation(linodeId, selectedDiskId ?? -1); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx index e7eafa6cad4..c94d597c3a3 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx @@ -24,7 +24,7 @@ export const LinodeWatchdogPanel = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: updateLinode, } = useLinodeUpdateMutation(linodeId); @@ -59,7 +59,7 @@ export const LinodeWatchdogPanel = (props: Props) => { label={ {linode?.watchdog_enabled ? 'Enabled' : 'Disabled'} - {isLoading && } + {isPending && } } disabled={isReadOnly} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/DeleteDiskDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/DeleteDiskDialog.tsx index 8bfd6b71dde..6ae127f9ad8 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/DeleteDiskDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/DeleteDiskDialog.tsx @@ -18,7 +18,7 @@ export const DeleteDiskDialog = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: deleteDisk, reset, } = useLinodeDeleteDiskMutation(linodeId, disk?.id ?? -1); @@ -41,7 +41,7 @@ export const DeleteDiskDialog = (props: Props) => { primaryButtonProps={{ 'data-testid': 'confirm-delete', label: 'Delete', - loading: isLoading, + loading: isPending, onClick: onDelete, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx index 6a4d181e61e..9c624845587 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MutationNotification.tsx @@ -45,7 +45,7 @@ export const MutationNotification = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: startMutation, } = useStartLinodeMutationMutation(linodeId); @@ -132,7 +132,7 @@ export const MutationNotification = (props: Props) => { handleClose={() => setIsMutationDrawerOpen(false)} initMutation={initMutation} linodeId={linodeId} - loading={isLoading} + loading={isPending} open={isMutationDrawerOpen} /> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/UpgradeVolumesDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/UpgradeVolumesDialog.tsx index 1d5c479e392..ec74531a2af 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/UpgradeVolumesDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/UpgradeVolumesDialog.tsx @@ -29,7 +29,7 @@ export const UpgradeVolumesDialog = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: migrateVolumes, } = useVolumesMigrateMutation(); @@ -58,7 +58,7 @@ export const UpgradeVolumesDialog = (props: Props) => { - diff --git a/packages/manager/src/features/Linodes/LinodesLanding/DeleteLinodeDialog.tsx b/packages/manager/src/features/Linodes/LinodesLanding/DeleteLinodeDialog.tsx index 57d5d7d40d7..bc60145831a 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/DeleteLinodeDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/DeleteLinodeDialog.tsx @@ -28,7 +28,7 @@ export const DeleteLinodeDialog = (props: Props) => { linodeId !== undefined && open ); - const { error, isLoading, mutateAsync, reset } = useDeleteLinodeMutation( + const { error, isPending, mutateAsync, reset } = useDeleteLinodeMutation( linodeId ?? -1 ); @@ -77,8 +77,8 @@ export const DeleteLinodeDialog = (props: Props) => { type: 'Linode', }} errors={error} - label={'Linode Label'} - loading={isLoading} + label="Linode Label" + loading={isPending} onClick={onDelete} onClose={onClose} open={open} diff --git a/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx b/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx index 65cc84a2465..bccdbf81d7b 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/MigrateLinode.tsx @@ -87,7 +87,7 @@ export const MigrateLinode = React.memo((props: Props) => { const { error, - isLoading, + isPending, mutateAsync: migrateLinode, reset, } = useLinodeMigrateMutation(linodeId ?? -1); @@ -294,7 +294,7 @@ export const MigrateLinode = React.memo((props: Props) => { }, }} buttonType="primary" - loading={isLoading} + loading={isPending} onClick={handleMigrate} > Enter Migration Queue diff --git a/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx b/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx index d69707fd69c..14a14fcd305 100644 --- a/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx +++ b/packages/manager/src/features/Linodes/PowerActionsDialogOrDrawer.tsx @@ -54,19 +54,19 @@ export const PowerActionsDialog = (props: Props) => { const { error: bootError, - isLoading: isBooting, + isPending: isBooting, mutateAsync: bootLinode, } = useBootLinodeMutation(linodeId ?? -1, configs); const { error: rebootError, - isLoading: isRebooting, + isPending: isRebooting, mutateAsync: rebootLinode, } = useRebootLinodeMutation(linodeId ?? -1, configs); const { error: shutdownError, - isLoading: isShuttingDown, + isPending: isShuttingDown, mutateAsync: shutdownLinode, } = useShutdownLinodeMutation(linodeId ?? -1); diff --git a/packages/manager/src/features/Lish/Glish.tsx b/packages/manager/src/features/Lish/Glish.tsx index f035e6f0944..c20835b0a86 100644 --- a/packages/manager/src/features/Lish/Glish.tsx +++ b/packages/manager/src/features/Lish/Glish.tsx @@ -10,16 +10,14 @@ import type { LinodeLishData } from '@linode/api-v4/lib/linodes'; import type { Linode } from '@linode/api-v4/lib/linodes'; import type { VncScreenHandle } from 'react-vnc'; -interface Props { +interface Props extends Omit { linode: Linode; refreshToken: () => Promise; } -type CombinedProps = Props & Omit; - let monitor: WebSocket; -const Glish = (props: CombinedProps) => { +const Glish = (props: Props) => { const { glish_url, linode, monitor_url, refreshToken, ws_protocols } = props; const ref = React.useRef(null); const [powered, setPowered] = React.useState(linode.status === 'running'); diff --git a/packages/manager/src/features/Lish/Lish.tsx b/packages/manager/src/features/Lish/Lish.tsx index b306ba80146..7bfbe47b4f4 100644 --- a/packages/manager/src/features/Lish/Lish.tsx +++ b/packages/manager/src/features/Lish/Lish.tsx @@ -86,7 +86,7 @@ const Lish = () => { await refetch(); }; - if (isLoading) { + if (isLoading || !linode || !data) { return ; } diff --git a/packages/manager/src/features/Lish/Weblish.tsx b/packages/manager/src/features/Lish/Weblish.tsx index d063a994c65..b487d68beae 100644 --- a/packages/manager/src/features/Lish/Weblish.tsx +++ b/packages/manager/src/features/Lish/Weblish.tsx @@ -8,7 +8,7 @@ import { ErrorState } from 'src/components/ErrorState/ErrorState'; import type { Linode } from '@linode/api-v4/lib/linodes'; import type { LinodeLishData } from '@linode/api-v4/lib/linodes'; -interface Props { +interface Props extends Pick { linode: Linode; refreshToken: () => Promise; } @@ -18,10 +18,7 @@ interface State { renderingLish: boolean; } -type CombinedProps = Props & - Pick; - -export class Weblish extends React.Component { +export class Weblish extends React.Component { mounted: boolean = false; socket: WebSocket; @@ -36,7 +33,7 @@ export class Weblish extends React.Component { this.connect(); } - componentDidUpdate(prevProps: CombinedProps) { + componentDidUpdate(prevProps: Props) { /* * If we have a new token, refresh the webosocket connection * and console with the new token diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index 16dd6225894..38179c78cb3 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -114,7 +114,7 @@ const NodeBalancerCreate = () => { const { error, - isLoading, + isPending, mutateAsync: createNodeBalancer, } = useNodebalancerCreateMutation(); @@ -687,7 +687,7 @@ const NodeBalancerCreate = () => { }} buttonType="primary" data-qa-deploy-nodebalancer - loading={isLoading} + loading={isPending} onClick={onCreate} tooltipText={isInvalidPrice ? PRICE_ERROR_TOOLTIP_TEXT : ''} > diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx index 92c3e3765e4..982963c6716 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.tsx @@ -19,7 +19,7 @@ export const NodeBalancerDeleteDialog = ({ onClose, open, }: Props) => { - const { error, isLoading, mutateAsync } = useNodebalancerDeleteMutation(id); + const { error, isPending, mutateAsync } = useNodebalancerDeleteMutation(id); const { push } = useHistory(); const onDelete = async () => { @@ -38,7 +38,7 @@ export const NodeBalancerDeleteDialog = ({ }} errors={error ?? undefined} label={'NodeBalancer Label'} - loading={isLoading} + loading={isPending} onClick={onDelete} onClose={onClose} open={open} diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx index beddb8e18d8..9afca8890d6 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx @@ -34,13 +34,13 @@ export const NodeBalancerSettings = () => { const { error: labelError, - isLoading: isUpdatingLabel, + isPending: isUpdatingLabel, mutateAsync: updateNodeBalancerLabel, } = useNodebalancerUpdateMutation(id); const { error: throttleError, - isLoading: isUpdatingThrottle, + isPending: isUpdatingThrottle, mutateAsync: updateNodeBalancerThrottle, } = useNodebalancerUpdateMutation(id); diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx index ce93bdbacfd..d05d46216b1 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketSSL.tsx @@ -1,6 +1,5 @@ -import { CreateObjectStorageBucketSSLPayload } from '@linode/api-v4/lib/object-storage'; -import Grid from '@mui/material/Unstable_Grid2'; import { useTheme } from '@mui/material/styles'; +import Grid from '@mui/material/Unstable_Grid2'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -29,6 +28,8 @@ import { StyledKeyWrapper, } from './BucketSSL.styles'; +import type { CreateObjectStorageBucketSSLPayload } from '@linode/api-v4'; + interface Props { bucketName: string; clusterId: string; @@ -80,7 +81,7 @@ export const SSLBody = (props: Props) => { const AddCertForm = (props: Props) => { const { bucketName, clusterId } = props; const { enqueueSnackbar } = useSnackbar(); - const { error, isLoading, mutateAsync } = useBucketSSLMutation( + const { error, isPending, mutateAsync } = useBucketSSLMutation( clusterId, bucketName ); @@ -145,7 +146,7 @@ const AddCertForm = (props: Props) => { @@ -160,7 +161,7 @@ const RemoveCertForm = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); const { error, - isLoading, + isPending, mutateAsync: deleteSSLCert, } = useBucketSSLDeleteMutation(clusterId, bucketName); @@ -177,11 +178,11 @@ const RemoveCertForm = (props: Props) => { setOpen(false), }} diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx index 99bbdb38be7..34f8cbfaed5 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx @@ -25,7 +25,7 @@ export const CreateFolderDrawer = (props: Props) => { prefix, } = props; - const { error, isLoading, mutateAsync } = useCreateObjectUrlMutation( + const { error, isPending, mutateAsync } = useCreateObjectUrlMutation( clusterId, bucketName ); @@ -88,7 +88,7 @@ export const CreateFolderDrawer = (props: Props) => { { const isInvalidPrice = !objTypes || !transferTypes || isErrorTypes || isErrorTransferTypes; - const { isLoading, mutateAsync: createBucket } = useCreateBucketMutation(); + const { isPending, mutateAsync: createBucket } = useCreateBucketMutation(); const { data: agreements } = useAccountAgreements(); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); const { data: accountSettings } = useAccountSettings(); @@ -200,7 +200,7 @@ export const CreateBucketDrawer = (props: Props) => { 'data-testid': 'create-bucket-button', disabled: (showGDPRCheckbox && !hasSignedAgreement) || isErrorTypes, label: 'Create Bucket', - loading: isLoading || Boolean(clusterRegion?.id && isLoadingTypes), + loading: isPending || Boolean(clusterRegion?.id && isLoadingTypes), tooltipText: !isLoadingTypes && isInvalidPrice ? PRICES_RELOAD_ERROR_NOTICE_TEXT diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx index 4cce7084f1a..d5b4e60e33d 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx @@ -95,7 +95,7 @@ export const OMC_CreateBucketDrawer = (props: Props) => { const isInvalidPrice = !objTypes || !transferTypes || isErrorTypes || isErrorTransferTypes; - const { isLoading, mutateAsync: createBucket } = useCreateBucketMutation(); + const { isPending, mutateAsync: createBucket } = useCreateBucketMutation(); const { data: agreements } = useAccountAgreements(); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); const { data: accountSettings } = useAccountSettings(); @@ -404,7 +404,7 @@ export const OMC_CreateBucketDrawer = (props: Props) => { disabled: (showGDPRCheckbox && !state.hasSignedAgreement) || isErrorTypes, label: 'Create Bucket', - loading: isLoading || Boolean(selectedRegion?.id && isLoadingTypes), + loading: isPending || Boolean(selectedRegion?.id && isLoadingTypes), tooltipText: !isLoadingTypes && isInvalidPrice ? PRICES_RELOAD_ERROR_NOTICE_TEXT diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.tsx index 476c0a826de..a7662421b52 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsAssignLinodesDrawer.tsx @@ -56,7 +56,7 @@ export const PlacementGroupsAssignLinodesDrawer = ( region, }); const { - isLoading, + isPending, mutateAsync: assignLinodes, } = useAssignLinodesToPlacementGroup(selectedPlacementGroup?.id ?? -1); const [selectedLinode, setSelectedLinode] = React.useState( @@ -176,7 +176,7 @@ export const PlacementGroupsAssignLinodesDrawer = ( setSelectedLinode(value); }} checkIsOptionEqualToValue - disabled={hasReachedCapacity || isLoading} + disabled={hasReachedCapacity || isPending} label={linodeSelectLabel} options={getLinodeSelectOptions()} placeholder="Select Linode or type to search" diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx index 43b451c6cca..383bbc74093 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDeleteModal.tsx @@ -43,7 +43,7 @@ export const PlacementGroupsDeleteModal = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); const { error: deletePlacementError, - isLoading: deletePlacementLoading, + isPending: deletePlacementLoading, mutateAsync: deletePlacementGroup, reset: resetDeletePlacementGroup, } = useDeletePlacementGroup(selectedPlacementGroup?.id ?? -1); diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.tsx index bc781ee0985..4db87ca1dfb 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsUnassignModal.tsx @@ -39,7 +39,7 @@ export const PlacementGroupsUnassignModal = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: unassignLinodes, } = useUnassignLinodesFromPlacementGroup(+placementGroupId ?? -1); @@ -78,9 +78,9 @@ export const PlacementGroupsUnassignModal = (props: Props) => { const actions = ( { const { error, - isLoading, + isPending, mutateAsync: createPersonalAccessToken, } = useCreatePersonalAccessTokenMutation(); @@ -367,7 +367,7 @@ export const CreateAPITokenDrawer = (props: Props) => { 'data-testid': 'create-button', disabled: !hasAccessBeenSelectedForAllScopes(form.values.scopes), label: 'Create Token', - loading: isLoading, + loading: isPending, onClick: () => form.handleSubmit(), }} secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} diff --git a/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.tsx index c64ae31140a..3aba5fb979f 100644 --- a/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.tsx +++ b/packages/manager/src/features/Profile/APITokens/EditAPITokenDrawer.tsx @@ -1,4 +1,3 @@ -import { Token, TokenRequest } from '@linode/api-v4/lib/profile/types'; import { useFormik } from 'formik'; import * as React from 'react'; @@ -9,6 +8,8 @@ import { TextField } from 'src/components/TextField'; import { useUpdatePersonalAccessTokenMutation } from 'src/queries/profile/tokens'; import { getErrorMap } from 'src/utilities/errorUtils'; +import type { Token, TokenRequest } from '@linode/api-v4'; + interface Props { onClose: () => void; open: boolean; @@ -20,7 +21,7 @@ export const EditAPITokenDrawer = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: updatePersonalAccessToken, } = useUpdatePersonalAccessTokenMutation(token?.id ?? -1); @@ -52,7 +53,7 @@ export const EditAPITokenDrawer = (props: Props) => { 'data-testid': 'save-button', disabled: !form.dirty, label: 'Save', - loading: isLoading, + loading: isPending, onClick: () => form.handleSubmit(), }} secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} diff --git a/packages/manager/src/features/Profile/APITokens/RevokeTokenDialog.tsx b/packages/manager/src/features/Profile/APITokens/RevokeTokenDialog.tsx index 039f2781ec4..3188d299a07 100644 --- a/packages/manager/src/features/Profile/APITokens/RevokeTokenDialog.tsx +++ b/packages/manager/src/features/Profile/APITokens/RevokeTokenDialog.tsx @@ -1,4 +1,3 @@ -import { Token } from '@linode/api-v4/lib/profile/types'; import { useSnackbar } from 'notistack'; import React from 'react'; @@ -10,7 +9,8 @@ import { useRevokePersonalAccessTokenMutation, } from 'src/queries/profile/tokens'; -import { APITokenType } from './APITokenTable'; +import type { APITokenType } from './APITokenTable'; +import type { Token } from '@linode/api-v4'; export interface Props { onClose: () => void; @@ -27,7 +27,7 @@ export const RevokeTokenDialog = ({ onClose, open, token, type }: Props) => { const useRevokeQuery = queryMap[type]; - const { error, isLoading, mutateAsync } = useRevokeQuery(token?.id ?? -1); + const { error, isPending, mutateAsync } = useRevokeQuery(token?.id ?? -1); const { enqueueSnackbar } = useSnackbar(); const onRevoke = () => { @@ -46,7 +46,7 @@ export const RevokeTokenDialog = ({ onClose, open, token, type }: Props) => { primaryButtonProps={{ 'data-testid': 'revoke-button', label: 'Revoke', - loading: isLoading, + loading: isPending, onClick: onRevoke, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx index 2e2894345c5..c22efc95617 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx @@ -62,7 +62,7 @@ export const PhoneVerification = ({ const { data, error: sendPhoneVerificationCodeError, - isLoading: isResending, + isPending: isResending, mutateAsync: resendPhoneVerificationCode, mutateAsync: sendPhoneVerificationCode, reset: resetSendCodeMutation, diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/RevokeTrustedDevicesDialog.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/RevokeTrustedDevicesDialog.tsx index 7558603e818..2eae511187b 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/RevokeTrustedDevicesDialog.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/RevokeTrustedDevicesDialog.tsx @@ -14,7 +14,7 @@ interface Props { export const RevokeTrustedDeviceDialog = (props: Props) => { const { deviceId, onClose, open } = props; - const { error, isLoading, mutateAsync } = useRevokeTrustedDeviceMutation( + const { error, isPending, mutateAsync } = useRevokeTrustedDeviceMutation( deviceId ); @@ -30,7 +30,7 @@ export const RevokeTrustedDeviceDialog = (props: Props) => { { const { data: profile } = useProfile(); const { error, - isLoading, + isPending, mutateAsync: optOut, reset, } = useSMSOptOutMutation(); @@ -80,7 +80,7 @@ export const SMSMessaging = () => { { const { data: securityQuestionsData, isLoading } = useSecurityQuestions(); const { - isLoading: isUpdating, + isPending: isUpdating, mutateAsync: updateSecurityQuestions, } = useMutateSecurityQuestions(); const { enqueueSnackbar } = useSnackbar(); diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/DisableTwoFactorDialog.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/DisableTwoFactorDialog.tsx index f60e346f1ec..22068358d91 100644 --- a/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/DisableTwoFactorDialog.tsx +++ b/packages/manager/src/features/Profile/AuthenticationSettings/TwoFactor/DisableTwoFactorDialog.tsx @@ -16,7 +16,7 @@ export const DisableTwoFactorDialog = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: disableTwoFactor, reset, } = useDisableTwoFactorMutation(); @@ -38,7 +38,7 @@ export const DisableTwoFactorDialog = (props: Props) => { primaryButtonProps={{ 'data-testid': 'submit', label: 'Disable Two-factor Authentication', - loading: isLoading, + loading: isPending, onClick: handleDisableTFA, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx b/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx index 9a1cd6bb1d1..3e871764f68 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/TimezoneForm.tsx @@ -7,10 +7,12 @@ import timezones from 'src/assets/timezones/timezones'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Box } from 'src/components/Box'; import { CircleProgress } from 'src/components/CircleProgress'; -import Select, { Item } from 'src/components/EnhancedSelect/Select'; +import Select from 'src/components/EnhancedSelect/Select'; import { Typography } from 'src/components/Typography'; import { useMutateProfile, useProfile } from 'src/queries/profile/profile'; +import type { Item } from 'src/components/EnhancedSelect/Select'; + interface Props { loggedInAsCustomer: boolean; } @@ -48,7 +50,7 @@ export const TimezoneForm = (props: Props) => { const { loggedInAsCustomer } = props; const { enqueueSnackbar } = useSnackbar(); const { data: profile } = useProfile(); - const { error, isLoading, mutateAsync: updateProfile } = useMutateProfile(); + const { error, isPending, mutateAsync: updateProfile } = useMutateProfile(); const [value, setValue] = React.useState | null>(null); const timezone = profile?.timezone ?? ''; @@ -111,7 +113,7 @@ export const TimezoneForm = (props: Props) => { primaryButtonProps={{ disabled, label: 'Update Timezone', - loading: isLoading, + loading: isPending, onClick: onSubmit, sx: { margin: '0', diff --git a/packages/manager/src/features/Profile/OAuthClients/CreateOAuthClientDrawer.tsx b/packages/manager/src/features/Profile/OAuthClients/CreateOAuthClientDrawer.tsx index e7f93935a15..36a783ae129 100644 --- a/packages/manager/src/features/Profile/OAuthClients/CreateOAuthClientDrawer.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/CreateOAuthClientDrawer.tsx @@ -1,4 +1,3 @@ -import { OAuthClientRequest } from '@linode/api-v4'; import { useFormik } from 'formik'; import * as React from 'react'; @@ -12,6 +11,8 @@ import { TextField } from 'src/components/TextField'; import { useCreateOAuthClientMutation } from 'src/queries/account/oauth'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; +import type { OAuthClientRequest } from '@linode/api-v4'; + interface Props { onClose: () => void; open: boolean; @@ -23,7 +24,7 @@ export const CreateOAuthClientDrawer = ({ open, showSecret, }: Props) => { - const { error, isLoading, mutateAsync } = useCreateOAuthClientMutation(); + const { error, isPending, mutateAsync } = useCreateOAuthClientMutation(); const formik = useFormik({ initialValues: { @@ -86,7 +87,7 @@ export const CreateOAuthClientDrawer = ({ { - const { error, isLoading, mutateAsync } = useDeleteOAuthClientMutation(id); + const { error, isPending, mutateAsync } = useDeleteOAuthClientMutation(id); const onDelete = () => { mutateAsync().then(() => { @@ -33,7 +33,7 @@ export const DeleteOAuthClientDialog = ({ primaryButtonProps={{ 'data-testid': 'button-confirm', label: 'Delete', - loading: isLoading, + loading: isPending, onClick: onDelete, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Profile/OAuthClients/EditOAuthClientDrawer.tsx b/packages/manager/src/features/Profile/OAuthClients/EditOAuthClientDrawer.tsx index be2f9ab8f97..67cf9149f7d 100644 --- a/packages/manager/src/features/Profile/OAuthClients/EditOAuthClientDrawer.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/EditOAuthClientDrawer.tsx @@ -1,17 +1,18 @@ -import { OAuthClient, OAuthClientRequest } from '@linode/api-v4'; import { useFormik } from 'formik'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Checkbox } from 'src/components/Checkbox'; import { Drawer } from 'src/components/Drawer'; -import { Notice } from 'src/components/Notice/Notice'; -import { TextField } from 'src/components/TextField'; import { FormControl } from 'src/components/FormControl'; import { FormControlLabel } from 'src/components/FormControlLabel'; +import { Notice } from 'src/components/Notice/Notice'; +import { TextField } from 'src/components/TextField'; import { useUpdateOAuthClientMutation } from 'src/queries/account/oauth'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; +import type { OAuthClient, OAuthClientRequest } from '@linode/api-v4'; + interface Props { client: OAuthClient | undefined; onClose: () => void; @@ -19,7 +20,7 @@ interface Props { } export const EditOAuthClientDrawer = ({ client, onClose, open }: Props) => { - const { error, isLoading, mutateAsync, reset } = useUpdateOAuthClientMutation( + const { error, isPending, mutateAsync, reset } = useUpdateOAuthClientMutation( client?.id ?? '' ); @@ -81,7 +82,7 @@ export const EditOAuthClientDrawer = ({ client, onClose, open }: Props) => { primaryButtonProps={{ disabled: !formik.dirty, label: 'Save Changes', - loading: isLoading, + loading: isPending, type: 'submit', }} secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} diff --git a/packages/manager/src/features/Profile/OAuthClients/ResetOAuthClientDialog.tsx b/packages/manager/src/features/Profile/OAuthClients/ResetOAuthClientDialog.tsx index 2779ca81c79..2e99357ffea 100644 --- a/packages/manager/src/features/Profile/OAuthClients/ResetOAuthClientDialog.tsx +++ b/packages/manager/src/features/Profile/OAuthClients/ResetOAuthClientDialog.tsx @@ -20,7 +20,7 @@ export const ResetOAuthClientDialog = ({ open, showSecret, }: Props) => { - const { error, isLoading, mutateAsync } = useResetOAuthClientMutation(id); + const { error, isPending, mutateAsync } = useResetOAuthClientMutation(id); const onReset = () => { mutateAsync().then((data) => { @@ -35,7 +35,7 @@ export const ResetOAuthClientDialog = ({ { const { enqueueSnackbar } = useSnackbar(); const { error, - isLoading, + isPending, mutateAsync: createSSHKey, } = useCreateSSHKeyMutation(); @@ -96,7 +96,7 @@ export const CreateSSHKeyDrawer = React.memo(({ onClose, open }: Props) => { primaryButtonProps={{ 'data-testid': 'submit', label: 'Add Key', - loading: isLoading, + loading: isPending, type: 'submit', }} secondaryButtonProps={{ label: 'Cancel', onClick: handleClose }} diff --git a/packages/manager/src/features/Profile/SSHKeys/DeleteSSHKeyDialog.tsx b/packages/manager/src/features/Profile/SSHKeys/DeleteSSHKeyDialog.tsx index 7b72b3913d7..5edca5addf9 100644 --- a/packages/manager/src/features/Profile/SSHKeys/DeleteSSHKeyDialog.tsx +++ b/packages/manager/src/features/Profile/SSHKeys/DeleteSSHKeyDialog.tsx @@ -13,7 +13,7 @@ interface Props { } const DeleteSSHKeyDialog = ({ id, label, onClose, open }: Props) => { - const { error, isLoading, mutateAsync } = useDeleteSSHKeyMutation(id); + const { error, isPending, mutateAsync } = useDeleteSSHKeyMutation(id); const onDelete = async () => { await mutateAsync(); @@ -27,7 +27,7 @@ const DeleteSSHKeyDialog = ({ id, label, onClose, open }: Props) => { primaryButtonProps={{ 'data-testid': 'confirm-delete', label: 'Delete', - loading: isLoading, + loading: isPending, onClick: onDelete, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Profile/SSHKeys/EditSSHKeyDrawer.tsx b/packages/manager/src/features/Profile/SSHKeys/EditSSHKeyDrawer.tsx index a1c09874fae..05a0393ecc5 100644 --- a/packages/manager/src/features/Profile/SSHKeys/EditSSHKeyDrawer.tsx +++ b/packages/manager/src/features/Profile/SSHKeys/EditSSHKeyDrawer.tsx @@ -1,8 +1,7 @@ -import { SSHKey } from '@linode/api-v4'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; -import { useEffect } from 'react'; import * as React from 'react'; +import { useEffect } from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; @@ -11,6 +10,8 @@ import { TextField } from 'src/components/TextField'; import { useUpdateSSHKeyMutation } from 'src/queries/profile/profile'; import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor'; +import type { SSHKey } from '@linode/api-v4'; + interface Props { onClose: () => void; open: boolean; @@ -21,7 +22,7 @@ const EditSSHKeyDrawer = ({ onClose, open, sshKey }: Props) => { const { enqueueSnackbar } = useSnackbar(); const { error, - isLoading, + isPending, mutateAsync: updateSSHKey, reset, } = useUpdateSSHKeyMutation(sshKey?.id ?? -1); @@ -75,7 +76,7 @@ const EditSSHKeyDrawer = ({ onClose, open, sshKey }: Props) => { 'data-testid': 'submit', disabled: !formik.dirty, label: 'Save', - loading: isLoading, + loading: isPending, type: 'submit', }} secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} diff --git a/packages/manager/src/features/Profile/Settings/Settings.tsx b/packages/manager/src/features/Profile/Settings/Settings.tsx index f383fdb5279..52eb8de3d9f 100644 --- a/packages/manager/src/features/Profile/Settings/Settings.tsx +++ b/packages/manager/src/features/Profile/Settings/Settings.tsx @@ -36,7 +36,7 @@ export const ProfileSettings = () => { }; const { data: profile } = useProfile(); - const { isLoading, mutateAsync: updateProfile } = useMutateProfile(); + const { isPending, mutateAsync: updateProfile } = useMutateProfile(); const { data: preferences } = usePreferences(); const { mutateAsync: updatePreferences } = useMutatePreferences(); @@ -70,7 +70,7 @@ export const ProfileSettings = () => { label={`Email alerts for account activity are ${ areEmailNotificationsEnabled ? 'enabled' : 'disabled' }`} - disabled={isLoading} + disabled={isPending} /> diff --git a/packages/manager/src/features/Search/SearchLanding.test.tsx b/packages/manager/src/features/Search/SearchLanding.test.tsx index d6509f04ce9..f2e660bd447 100644 --- a/packages/manager/src/features/Search/SearchLanding.test.tsx +++ b/packages/manager/src/features/Search/SearchLanding.test.tsx @@ -1,4 +1,4 @@ -import { render, waitForElementToBeRemoved } from '@testing-library/react'; +import { render } from '@testing-library/react'; import { assocPath } from 'ramda'; import * as React from 'react'; @@ -9,9 +9,11 @@ import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; -import { SearchLandingProps as Props, SearchLanding } from './SearchLanding'; +import { SearchLanding } from './SearchLanding'; import { emptyResults } from './utils'; +import type { SearchLandingProps as Props } from './SearchLanding'; + const props: Props = { combinedResults: [], entities: [], @@ -50,10 +52,7 @@ describe('Component', () => { '?query=search', propsWithResults ); - const { getByTestId, getByText } = renderWithTheme( - - ); - await waitForElementToBeRemoved(getByTestId('loading')); + const { getByText } = renderWithTheme(); getByText(/search/i); expect(props.search).toHaveBeenCalled(); }); diff --git a/packages/manager/src/features/Support/SupportTicketDetail/CloseTicketLink.tsx b/packages/manager/src/features/Support/SupportTicketDetail/CloseTicketLink.tsx index 705ea2ee9c2..948a57bf672 100644 --- a/packages/manager/src/features/Support/SupportTicketDetail/CloseTicketLink.tsx +++ b/packages/manager/src/features/Support/SupportTicketDetail/CloseTicketLink.tsx @@ -1,4 +1,3 @@ -import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -7,6 +6,8 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { Typography } from 'src/components/Typography'; import { useSupportTicketCloseMutation } from 'src/queries/support'; +import type { Theme } from '@mui/material/styles'; + const useStyles = makeStyles()((theme: Theme) => ({ closeLink: { ...theme.applyLinkStyles, @@ -24,7 +25,7 @@ export const CloseTicketLink = ({ ticketId }: Props) => { const { error, - isLoading, + isPending, mutateAsync: closeSupportTicket, } = useSupportTicketCloseMutation(ticketId); @@ -38,7 +39,7 @@ export const CloseTicketLink = ({ ticketId }: Props) => { primaryButtonProps={{ 'data-testid': 'dialog-submit', label: 'Confirm', - loading: isLoading, + loading: isPending, onClick: closeTicket, }} secondaryButtonProps={{ diff --git a/packages/manager/src/features/Users/UserDeleteConfirmationDialog.tsx b/packages/manager/src/features/Users/UserDeleteConfirmationDialog.tsx index 15bc90406ae..5e95cdfecb1 100644 --- a/packages/manager/src/features/Users/UserDeleteConfirmationDialog.tsx +++ b/packages/manager/src/features/Users/UserDeleteConfirmationDialog.tsx @@ -21,7 +21,7 @@ export const UserDeleteConfirmationDialog = (props: Props) => { error, mutateAsync: deleteUser, reset, - isLoading, + isPending, } = useAccountUserDeleteMutation(username); const onClose = () => { @@ -46,7 +46,7 @@ export const UserDeleteConfirmationDialog = (props: Props) => { { }); // Update the user's grants directly in the cache - this.props.queryClient.setQueriesData( + this.props.queryClient.setQueryData( accountQueries.users._ctx.user(currentUsername)._ctx.grants .queryKey, grantsResponse diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx index f4b3a9527cb..0eaf06dca68 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetCreateDrawer.tsx @@ -10,12 +10,13 @@ import { useCreateSubnetMutation, useVPCQuery } from 'src/queries/vpcs/vpcs'; import { getErrorMap } from 'src/utilities/errorUtils'; import { DEFAULT_SUBNET_IPV4_VALUE, - SubnetFieldState, getRecommendedSubnetIPv4, } from 'src/utilities/subnets'; import { SubnetNode } from '../VPCCreate/SubnetNode'; +import type { SubnetFieldState } from 'src/utilities/subnets'; + interface Props { onClose: () => void; open: boolean; @@ -41,7 +42,7 @@ export const SubnetCreateDrawer = (props: Props) => { >({}); const { - isLoading, + isPending, mutateAsync: createSubnet, reset, } = useCreateSubnetMutation(vpcId); @@ -114,7 +115,7 @@ export const SubnetCreateDrawer = (props: Props) => { 'data-testid': 'create-subnet-drawer-button', disabled: !dirty || userCannotAddSubnet, label: 'Create Subnet', - loading: isLoading, + loading: isPending, onClick: onCreateSubnet, type: 'submit', }} diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetDeleteDialog.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetDeleteDialog.tsx index fe1873b20f0..12d87de7eea 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetDeleteDialog.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetDeleteDialog.tsx @@ -1,10 +1,11 @@ -import { Subnet } from '@linode/api-v4'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { useDeleteSubnetMutation } from 'src/queries/vpcs/vpcs'; +import type { Subnet } from '@linode/api-v4'; + interface Props { onClose: () => void; open: boolean; @@ -17,7 +18,7 @@ export const SubnetDeleteDialog = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); const { error, - isLoading, + isPending, mutateAsync: deleteSubnet, reset, } = useDeleteSubnetMutation(vpcId, subnet?.id ?? -1); @@ -46,7 +47,7 @@ export const SubnetDeleteDialog = (props: Props) => { }} errors={error} label="Subnet Label" - loading={isLoading} + loading={isPending} onClick={onDeleteSubnet} onClose={onClose} open={open} diff --git a/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx b/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx index e5b5279fbf4..28b079c9bb2 100644 --- a/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCDetail/SubnetEditDrawer.tsx @@ -1,5 +1,3 @@ -import { ModifySubnetPayload } from '@linode/api-v4/lib/vpcs/types'; -import { Subnet } from '@linode/api-v4/lib/vpcs/types'; import { useFormik } from 'formik'; import * as React from 'react'; @@ -11,6 +9,8 @@ import { useGrants, useProfile } from 'src/queries/profile/profile'; import { useUpdateSubnetMutation } from 'src/queries/vpcs/vpcs'; import { getErrorMap } from 'src/utilities/errorUtils'; +import type { ModifySubnetPayload, Subnet } from '@linode/api-v4'; + interface Props { onClose: () => void; open: boolean; @@ -26,7 +26,7 @@ export const SubnetEditDrawer = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: updateSubnet, reset, } = useUpdateSubnetMutation(vpcId, subnet?.id ?? -1); @@ -92,7 +92,7 @@ export const SubnetEditDrawer = (props: Props) => { 'data-testid': 'save-button', disabled: !form.dirty, label: 'Save', - loading: isLoading, + loading: isPending, type: 'submit', }} secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCDeleteDialog.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCDeleteDialog.tsx index 6eac860e1bd..e60dbfd219f 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCDeleteDialog.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCDeleteDialog.tsx @@ -17,7 +17,7 @@ export const VPCDeleteDialog = (props: Props) => { const { enqueueSnackbar } = useSnackbar(); const { error, - isLoading, + isPending, mutateAsync: deleteVPC, reset, } = useDeleteVPCMutation(id ?? -1); @@ -51,7 +51,7 @@ export const VPCDeleteDialog = (props: Props) => { }} errors={error} label="VPC Label" - loading={isLoading} + loading={isPending} onClick={onDeleteVPC} onClose={onClose} open={open} diff --git a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx index 73935355e6d..d8264de1f43 100644 --- a/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx +++ b/packages/manager/src/features/VPCs/VPCLanding/VPCEditDrawer.tsx @@ -38,7 +38,7 @@ export const VPCEditDrawer = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: updateVPC, reset, } = useUpdateVPCMutation(vpc?.id ?? -1); @@ -132,7 +132,7 @@ export const VPCEditDrawer = (props: Props) => { 'data-testid': 'save-button', disabled: !form.dirty || readOnly, label: 'Save', - loading: isLoading, + loading: isPending, type: 'submit', }} secondaryButtonProps={{ label: 'Cancel', onClick: onClose }} diff --git a/packages/manager/src/features/Volumes/DeleteVolumeDialog.tsx b/packages/manager/src/features/Volumes/DeleteVolumeDialog.tsx index d094eac48d5..cc0db32476a 100644 --- a/packages/manager/src/features/Volumes/DeleteVolumeDialog.tsx +++ b/packages/manager/src/features/Volumes/DeleteVolumeDialog.tsx @@ -1,4 +1,3 @@ -import { Volume } from '@linode/api-v4'; import * as React from 'react'; import { Notice } from 'src/components/Notice/Notice'; @@ -6,6 +5,8 @@ import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToCo import { useEventsPollingActions } from 'src/queries/events/events'; import { useDeleteVolumeMutation } from 'src/queries/volumes/volumes'; +import type { Volume } from '@linode/api-v4'; + interface Props { onClose: () => void; open: boolean; @@ -17,7 +18,7 @@ export const DeleteVolumeDialog = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: deleteVolume, } = useDeleteVolumeMutation(); @@ -39,7 +40,7 @@ export const DeleteVolumeDialog = (props: Props) => { type: 'Volume', }} label="Volume Label" - loading={isLoading} + loading={isPending} onClick={onDelete} onClose={onClose} open={open} diff --git a/packages/manager/src/features/Volumes/DetachVolumeDialog.tsx b/packages/manager/src/features/Volumes/DetachVolumeDialog.tsx index 6c72324fa7a..4e1d7d6e186 100644 --- a/packages/manager/src/features/Volumes/DetachVolumeDialog.tsx +++ b/packages/manager/src/features/Volumes/DetachVolumeDialog.tsx @@ -1,4 +1,3 @@ -import { Volume } from '@linode/api-v4'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -9,6 +8,8 @@ import { useEventsPollingActions } from 'src/queries/events/events'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; import { useDetachVolumeMutation } from 'src/queries/volumes/volumes'; +import type { Volume } from '@linode/api-v4'; + interface Props { onClose: () => void; open: boolean; @@ -29,7 +30,7 @@ export const DetachVolumeDialog = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: detachVolume, } = useDetachVolumeMutation(); @@ -54,7 +55,7 @@ export const DetachVolumeDialog = (props: Props) => { type: 'Volume', }} label="Volume Label" - loading={isLoading} + loading={isPending} onClick={onDetach} onClose={onClose} open={open} diff --git a/packages/manager/src/features/Volumes/UpgradeVolumeDialog.tsx b/packages/manager/src/features/Volumes/UpgradeVolumeDialog.tsx index 4747fbd8ffb..fa297d26cb4 100644 --- a/packages/manager/src/features/Volumes/UpgradeVolumeDialog.tsx +++ b/packages/manager/src/features/Volumes/UpgradeVolumeDialog.tsx @@ -48,7 +48,7 @@ export const UpgradeVolumeDialog = (props: Props) => { const { error, - isLoading, + isPending, mutateAsync: migrateVolumes, } = useVolumesMigrateMutation(); @@ -71,7 +71,7 @@ export const UpgradeVolumeDialog = (props: Props) => { { >(); const { - isLoading: isLoadingCreateVPC, + isPending: isLoadingCreateVPC, mutateAsync: createVPC, } = useCreateVPCMutation(); diff --git a/packages/manager/src/index.tsx b/packages/manager/src/index.tsx index 06a0153ff9d..729f8054e8a 100644 --- a/packages/manager/src/index.tsx +++ b/packages/manager/src/index.tsx @@ -80,10 +80,7 @@ const Main = () => { - + ); diff --git a/packages/manager/src/queries/account/account.ts b/packages/manager/src/queries/account/account.ts index 14c250d610f..3f75bc4c522 100644 --- a/packages/manager/src/queries/account/account.ts +++ b/packages/manager/src/queries/account/account.ts @@ -41,7 +41,8 @@ export const useMutateAccount = () => { const { enqueueSnackbar } = useSnackbar(); const { isTaxIdEnabled } = useIsTaxIdEnabled(); - return useMutation>(updateAccountInfo, { + return useMutation>({ + mutationFn: updateAccountInfo, onSuccess(account) { queryClient.setQueryData( accountQueries.account.queryKey, @@ -89,13 +90,13 @@ export const useChildAccountsInfiniteQuery = (options: RequestOptions) => { } return page + 1; }, - keepPreviousData: true, + initialPageParam: 1, ...accountQueries.childAccounts(options), }); }; export const useCreateChildAccountPersonalAccessTokenMutation = () => - useMutation( - ({ euuid, headers }: ChildAccountPayload) => - createChildAccountPersonalAccessToken({ euuid, headers }) - ); + useMutation({ + mutationFn: ({ euuid, headers }: ChildAccountPayload) => + createChildAccountPersonalAccessToken({ euuid, headers }), + }); diff --git a/packages/manager/src/queries/account/betas.ts b/packages/manager/src/queries/account/betas.ts index 808b1aab8f0..fb6bca48f0f 100644 --- a/packages/manager/src/queries/account/betas.ts +++ b/packages/manager/src/queries/account/betas.ts @@ -9,7 +9,12 @@ import { Params, ResourcePage, } from '@linode/api-v4/lib/types'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import { regionQueries } from '../regions/regions'; import { accountQueries } from './queries'; @@ -17,7 +22,7 @@ import { accountQueries } from './queries'; export const useAccountBetasQuery = (params?: Params, filter?: Filter) => useQuery, APIError[]>({ ...accountQueries.betas._ctx.paginated(params, filter), - keepPreviousData: true, + placeholderData: keepPreviousData, }); export const useCreateAccountBetaMutation = () => { diff --git a/packages/manager/src/queries/account/billing.ts b/packages/manager/src/queries/account/billing.ts index 9eddbf90160..a5ebd43c181 100644 --- a/packages/manager/src/queries/account/billing.ts +++ b/packages/manager/src/queries/account/billing.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { queryPresets } from '../base'; import { accountQueries } from './queries'; @@ -14,7 +14,7 @@ export const useAllAccountInvoices = ( return useQuery({ ...accountQueries.invoices(params, filter), ...queryPresets.oneTimeFetch, - keepPreviousData: true, + placeholderData: keepPreviousData, }); }; @@ -25,7 +25,7 @@ export const useAllAccountPayments = ( return useQuery({ ...accountQueries.payments(params, filter), ...queryPresets.oneTimeFetch, - keepPreviousData: true, + placeholderData: keepPreviousData, }); }; diff --git a/packages/manager/src/queries/account/logins.ts b/packages/manager/src/queries/account/logins.ts index 373a353ccdd..fecc4fbcfdb 100644 --- a/packages/manager/src/queries/account/logins.ts +++ b/packages/manager/src/queries/account/logins.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { accountQueries } from './queries'; @@ -13,5 +13,5 @@ import type { export const useAccountLoginsQuery = (params?: Params, filter?: Filter) => useQuery, APIError[]>({ ...accountQueries.logins(params, filter), - keepPreviousData: true, + placeholderData: keepPreviousData, }); diff --git a/packages/manager/src/queries/account/maintenance.ts b/packages/manager/src/queries/account/maintenance.ts index ace000c3871..9f8d41b3f2e 100644 --- a/packages/manager/src/queries/account/maintenance.ts +++ b/packages/manager/src/queries/account/maintenance.ts @@ -5,7 +5,7 @@ import { Params, ResourcePage, } from '@linode/api-v4/lib/types'; -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { queryPresets } from '../base'; import { accountQueries } from './queries'; @@ -25,7 +25,7 @@ export const useAllAccountMaintenanceQuery = ( export const useAccountMaintenanceQuery = (params: Params, filter: Filter) => { return useQuery, APIError[]>({ ...accountQueries.maintenance._ctx.paginated(params, filter), - keepPreviousData: true, + placeholderData: keepPreviousData, refetchInterval: 20000, refetchOnWindowFocus: 'always', }); diff --git a/packages/manager/src/queries/account/oauth.ts b/packages/manager/src/queries/account/oauth.ts index 50a0fd75b88..bc260849d78 100644 --- a/packages/manager/src/queries/account/oauth.ts +++ b/packages/manager/src/queries/account/oauth.ts @@ -9,7 +9,12 @@ import { resetOAuthClientSecret, updateOAuthClient, } from '@linode/api-v4'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import { EventHandlerData } from 'src/hooks/useEventHandlers'; @@ -18,7 +23,7 @@ import { accountQueries } from './queries'; export const useOAuthClientsQuery = (params?: Params, filter?: Filter) => useQuery({ ...accountQueries.oauthClients(params, filter), - keepPreviousData: true, + placeholderData: keepPreviousData, }); interface OAuthClientWithSecret extends OAuthClient { diff --git a/packages/manager/src/queries/account/queries.ts b/packages/manager/src/queries/account/queries.ts index 55e405d724b..a874bd50381 100644 --- a/packages/manager/src/queries/account/queries.ts +++ b/packages/manager/src/queries/account/queries.ts @@ -59,7 +59,7 @@ export const accountQueries = createQueryKeys('account', { filter: options.filter, headers: options.headers, params: { - page: pageParam, + page: pageParam as number, page_size: 25, }, }), diff --git a/packages/manager/src/queries/account/users.ts b/packages/manager/src/queries/account/users.ts index 4828eb78348..b8cd36863aa 100644 --- a/packages/manager/src/queries/account/users.ts +++ b/packages/manager/src/queries/account/users.ts @@ -1,5 +1,10 @@ import { deleteUser } from '@linode/api-v4/lib/account'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import { useProfile } from 'src/queries/profile/profile'; @@ -28,7 +33,7 @@ export const useAccountUsers = ({ return useQuery, APIError[]>({ ...accountQueries.users._ctx.paginated(params, filters), enabled: enabled && !profile?.restricted, - keepPreviousData: true, + placeholderData: keepPreviousData, }); }; @@ -48,7 +53,8 @@ export const useAccountUserGrants = (username: string) => { export const useAccountUserDeleteMutation = (username: string) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => deleteUser(username), { + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteUser(username), onSuccess() { queryClient.invalidateQueries({ queryKey: accountQueries.users._ctx.paginated._def, diff --git a/packages/manager/src/queries/base.ts b/packages/manager/src/queries/base.ts index b1728a0a770..ce91c6c8370 100644 --- a/packages/manager/src/queries/base.ts +++ b/packages/manager/src/queries/base.ts @@ -250,7 +250,7 @@ export const updateInPaginatedStore = ( queryClient: QueryClient ) => { queryClient.setQueriesData | undefined>( - queryKey, + { queryKey }, (oldData) => { if (oldData === undefined) { return undefined; @@ -286,9 +286,9 @@ export const getItemInPaginatedStore = ( id: number, queryClient: QueryClient ) => { - const stores = queryClient.getQueriesData | undefined>( - queryKey - ); + const stores = queryClient.getQueriesData | undefined>({ + queryKey, + }); for (const store of stores) { const data = store[1]?.data; diff --git a/packages/manager/src/queries/betas.ts b/packages/manager/src/queries/betas.ts index 93fc151db4e..61212e53d0f 100644 --- a/packages/manager/src/queries/betas.ts +++ b/packages/manager/src/queries/betas.ts @@ -6,7 +6,7 @@ import { ResourcePage, } from '@linode/api-v4/lib/types'; import { createQueryKeys } from '@lukemorales/query-key-factory'; -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; export const betaQueries = createQueryKeys('betas', { beta: (id: string) => ({ @@ -22,7 +22,7 @@ export const betaQueries = createQueryKeys('betas', { export const useBetasQuery = (params?: Params, filter?: Filter) => useQuery, APIError[]>({ ...betaQueries.paginated(params, filter), - keepPreviousData: true, + placeholderData: keepPreviousData, }); export const useBetaQuery = (id: string) => diff --git a/packages/manager/src/queries/cloudpulse/customfilters.ts b/packages/manager/src/queries/cloudpulse/customfilters.ts index 72ea43af0c4..f89677ebbc9 100644 --- a/packages/manager/src/queries/cloudpulse/customfilters.ts +++ b/packages/manager/src/queries/cloudpulse/customfilters.ts @@ -54,7 +54,7 @@ export const useGetCustomFiltersQuery = ( >({ // receive filters and return only id and label enabled: enabled && apiV4QueryKey !== undefined, - ...(apiV4QueryKey ?? {}), + ...(apiV4QueryKey ?? { queryFn: () => [], queryKey: [''] }), select: ( filters: QueryFunctionType ): CloudPulseServiceTypeFiltersOptions[] => { diff --git a/packages/manager/src/queries/cloudpulse/metrics.ts b/packages/manager/src/queries/cloudpulse/metrics.ts index de200c3c040..d949ed371c4 100644 --- a/packages/manager/src/queries/cloudpulse/metrics.ts +++ b/packages/manager/src/queries/cloudpulse/metrics.ts @@ -1,5 +1,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import Axios from 'axios'; +import { useEffect } from 'react'; import { queryFactory } from './queries'; @@ -25,7 +26,8 @@ export const useCloudPulseMetricsQuery = ( } ) => { const queryClient = useQueryClient(); - return useQuery({ + + const query = useQuery({ ...queryFactory.metrics( obj.authToken, obj.url, @@ -36,30 +38,37 @@ export const useCloudPulseMetricsQuery = ( ), enabled: !!obj.isFlags, - onError(err: APIError[]) { - if (err && err.length > 0 && err[0].reason == 'Token expired') { - const currentJWEtokenCache: - | JWEToken - | undefined = queryClient.getQueryData( - queryFactory.token(serviceType, { resource_ids: [] }).queryKey - ); - if (currentJWEtokenCache?.token === obj.authToken) { - queryClient.invalidateQueries( - { - queryKey: queryFactory.token(serviceType, { resource_ids: [] }) - .queryKey, - }, - { - cancelRefetch: true, - } - ); - } - } - }, refetchInterval: 120000, refetchOnWindowFocus: false, retry: 0, }); + + useEffect(() => { + if ( + query.error && + query.error.length > 0 && + query.error[0].reason == 'Token expired' + ) { + const currentJWEtokenCache: + | JWEToken + | undefined = queryClient.getQueryData( + queryFactory.token(serviceType, { resource_ids: [] }).queryKey + ); + if (currentJWEtokenCache?.token === obj.authToken) { + queryClient.invalidateQueries( + { + queryKey: queryFactory.token(serviceType, { resource_ids: [] }) + .queryKey, + }, + { + cancelRefetch: true, + } + ); + } + } + }, [query.error]); + + return query; }; export const fetchCloudPulseMetrics = ( diff --git a/packages/manager/src/queries/cloudpulse/services.ts b/packages/manager/src/queries/cloudpulse/services.ts index 410d9938cd6..743aad0ac5e 100644 --- a/packages/manager/src/queries/cloudpulse/services.ts +++ b/packages/manager/src/queries/cloudpulse/services.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { queryFactory } from './queries'; @@ -28,7 +28,7 @@ export const useCloudPulseJWEtokenQuery = ( return useQuery({ ...queryFactory.token(serviceType, request), enabled: runQuery, - keepPreviousData: true, + placeholderData: keepPreviousData, refetchOnWindowFocus: false, }); }; diff --git a/packages/manager/src/queries/databases/databases.ts b/packages/manager/src/queries/databases/databases.ts index 11e5ed1aab9..89062f4fcd9 100644 --- a/packages/manager/src/queries/databases/databases.ts +++ b/packages/manager/src/queries/databases/databases.ts @@ -10,7 +10,12 @@ import { updateDatabase, } from '@linode/api-v4/lib/databases'; import { createQueryKeys } from '@lukemorales/query-key-factory'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import { queryPresets } from '../base'; import { profileQueries } from '../profile/profile'; @@ -87,7 +92,7 @@ export const useDatabaseQuery = (engine: Engine, id: number) => export const useDatabasesQuery = (params: Params, filter: Filter) => useQuery, APIError[]>({ ...databaseQueries.databases._ctx.paginated(params, filter), - keepPreviousData: true, + placeholderData: keepPreviousData, // @TODO Consider removing polling refetchInterval: 20000, }); diff --git a/packages/manager/src/queries/domains.ts b/packages/manager/src/queries/domains.ts index 527ac5637c4..610f153d28f 100644 --- a/packages/manager/src/queries/domains.ts +++ b/packages/manager/src/queries/domains.ts @@ -9,7 +9,12 @@ import { updateDomain, } from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import { getAll } from 'src/utilities/getAll'; @@ -66,7 +71,7 @@ const domainQueries = createQueryKeys('domains', { export const useDomainsQuery = (params: Params, filter: Filter) => useQuery, APIError[]>({ ...domainQueries.domains._ctx.paginated(params, filter), - keepPreviousData: true, + placeholderData: keepPreviousData, }); export const useAllDomainsQuery = (enabled: boolean = false) => diff --git a/packages/manager/src/queries/entityTransfers.ts b/packages/manager/src/queries/entityTransfers.ts index 2480d06f81d..07c656f59dd 100644 --- a/packages/manager/src/queries/entityTransfers.ts +++ b/packages/manager/src/queries/entityTransfers.ts @@ -51,30 +51,30 @@ export const useEntityTransfersQuery = ( ) => { const { data: profile } = useProfile(); - return useQuery( - [queryKey, params, filter], - () => getAllEntityTransfersRequest(params, filter), - { - ...queryPresets.longLived, - enabled: !profile?.restricted, - } - ); + return useQuery({ + queryFn: () => getAllEntityTransfersRequest(params, filter), + queryKey: [queryKey, params, filter], + ...queryPresets.longLived, + enabled: !profile?.restricted, + }); }; export const useTransferQuery = (token: string, enabled: boolean = true) => { - return useQuery( - [queryKey, token], - () => getEntityTransfer(token), - { ...queryPresets.shortLived, enabled, retry: false } - ); + return useQuery({ + queryFn: () => getEntityTransfer(token), + queryKey: [queryKey, token], + ...queryPresets.shortLived, + enabled, + retry: false, + }); }; export const useCreateTransfer = () => { const queryClient = useQueryClient(); - return useMutation( - (createData) => { + return useMutation({ + mutationFn: (createData) => { return createEntityTransfer(createData); }, - creationHandlers([queryKey], 'token', queryClient) - ); + ...creationHandlers([queryKey], 'token', queryClient), + }); }; diff --git a/packages/manager/src/queries/events/events.ts b/packages/manager/src/queries/events/events.ts index c6ceb7b073a..95009a10fc8 100644 --- a/packages/manager/src/queries/events/events.ts +++ b/packages/manager/src/queries/events/events.ts @@ -6,7 +6,7 @@ import { useQueryClient, } from '@tanstack/react-query'; import { DateTime } from 'luxon'; -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { ISO_DATETIME_NO_TZ_FORMAT, POLLING_INTERVALS } from 'src/constants'; import { EVENTS_LIST_FILTER } from 'src/features/Events/constants'; @@ -40,13 +40,14 @@ import type { */ export const useEventsInfiniteQuery = (filter: Filter = EVENTS_LIST_FILTER) => { const query = useInfiniteQuery, APIError[]>({ - cacheTime: Infinity, + gcTime: Infinity, getNextPageParam: ({ data, results }) => { if (results === data.length) { return undefined; } return data[data.length - 1].id; }, + initialPageParam: undefined, queryFn: ({ pageParam }) => getEvents( {}, @@ -114,18 +115,8 @@ export const useEventsPoller = () => { DateTime.now().setZone('utc').toFormat(ISO_DATETIME_NO_TZ_FORMAT) ); - useQuery({ + const { data: polledEvents } = useQuery({ enabled: hasFetchedInitialEvents, - onSuccess(events) { - if (events.length > 0) { - updateEventsQueries(events, queryClient); - - for (const event of events) { - handleGlobalToast(event); - handleEvent(event); - } - } - }, queryFn: () => { const data = queryClient.getQueryData>>([ 'events', @@ -160,8 +151,8 @@ export const useEventsPoller = () => { return getEvents({}, filter).then((data) => data.data); }, queryKey: ['events', 'poller'], - refetchInterval: (data) => { - const hasInProgressEvents = data?.some(isInProgressEvent); + refetchInterval: (query) => { + const hasInProgressEvents = query.state.data?.some(isInProgressEvent); if (hasInProgressEvents) { return POLLING_INTERVALS.IN_PROGRESS; } @@ -169,6 +160,17 @@ export const useEventsPoller = () => { }, }); + useEffect(() => { + if (polledEvents && polledEvents.length > 0) { + updateEventsQueries(polledEvents, queryClient); + + for (const event of polledEvents) { + handleGlobalToast(event); + handleEvent(event); + } + } + }, [polledEvents]); + return null; }; @@ -203,42 +205,40 @@ export const useEventsPollingActions = () => { export const useMarkEventsAsSeen = () => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[], number>( - (eventId) => markEventSeen(eventId), - { - onSuccess: (_, eventId) => { - queryClient.setQueriesData>>( - ['events', 'infinite'], - (prev) => { - if (!prev) { - return { - pageParams: [], - pages: [], - }; - } + return useMutation<{}, APIError[], number>({ + mutationFn: (eventId) => markEventSeen(eventId), + onSuccess: (_, eventId) => { + queryClient.setQueriesData>>( + { queryKey: ['events', 'infinite'] }, + (prev) => { + if (!prev) { + return { + pageParams: [], + pages: [], + }; + } - let foundLatestSeenEvent = false; + let foundLatestSeenEvent = false; - for (const page of prev.pages) { - for (const event of page.data) { - if (event.id === eventId) { - foundLatestSeenEvent = true; - } - if (foundLatestSeenEvent) { - event.seen = true; - } + for (const page of prev.pages) { + for (const event of page.data) { + if (event.id === eventId) { + foundLatestSeenEvent = true; + } + if (foundLatestSeenEvent) { + event.seen = true; } } - - return { - pageParams: prev?.pageParams ?? [], - pages: prev?.pages ?? [], - }; } - ); - }, - } - ); + + return { + pageParams: prev?.pageParams ?? [], + pages: prev?.pages ?? [], + }; + } + ); + }, + }); }; /** @@ -254,7 +254,7 @@ export const updateEventsQueries = ( ) => { queryClient .getQueryCache() - .findAll(['events', 'infinite']) + .findAll({ queryKey: ['events', 'infinite'] }) .forEach(({ queryKey }) => { const apiFilter = queryKey[queryKey.length - 1] as Filter | undefined; diff --git a/packages/manager/src/queries/firewalls.ts b/packages/manager/src/queries/firewalls.ts index bda609a9819..d018281c790 100644 --- a/packages/manager/src/queries/firewalls.ts +++ b/packages/manager/src/queries/firewalls.ts @@ -12,7 +12,12 @@ import { updateFirewallRules, } from '@linode/api-v4/lib/firewalls'; import { createQueryKeys } from '@lukemorales/query-key-factory'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import { getAll } from 'src/utilities/getAll'; @@ -101,7 +106,7 @@ export const useAddFirewallDeviceMutation = (id: number) => { onSuccess(firewallDevice) { // Append the new entity to the Firewall object in the paginated store queryClient.setQueriesData>( - firewallQueries.firewalls._ctx.paginated._def, + { queryKey: firewallQueries.firewalls._ctx.paginated._def }, (page) => { if (!page) { return undefined; @@ -236,7 +241,7 @@ export const useRemoveFirewallDeviceMutation = ( export const useFirewallsQuery = (params?: Params, filter?: Filter) => { return useQuery, APIError[]>({ ...firewallQueries.firewalls._ctx.paginated(params, filter), - keepPreviousData: true, + placeholderData: keepPreviousData, }); }; @@ -346,7 +351,7 @@ export const useUpdateFirewallRulesMutation = (firewallId: number) => { // Update the Firewall object in the paginated store queryClient.setQueriesData>( - firewallQueries.firewalls._ctx.paginated._def, + { queryKey: firewallQueries.firewalls._ctx.paginated._def }, (page) => { if (!page) { return undefined; diff --git a/packages/manager/src/queries/images.ts b/packages/manager/src/queries/images.ts index e79b1d5ed9b..16aa84813cc 100644 --- a/packages/manager/src/queries/images.ts +++ b/packages/manager/src/queries/images.ts @@ -8,7 +8,12 @@ import { uploadImage, } from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import { getAll } from 'src/utilities/getAll'; @@ -54,11 +59,11 @@ export const imageQueries = createQueryKeys('images', { export const useImagesQuery = ( params: Params, filters: Filter, - options?: UseQueryOptions, APIError[]> + options?: Partial, APIError[]>> ) => useQuery, APIError[]>({ ...imageQueries.paginated(params, filters), - keepPreviousData: true, + placeholderData: keepPreviousData, ...options, }); diff --git a/packages/manager/src/queries/kubernetes.ts b/packages/manager/src/queries/kubernetes.ts index 66b70b73fb9..86b79eaf662 100644 --- a/packages/manager/src/queries/kubernetes.ts +++ b/packages/manager/src/queries/kubernetes.ts @@ -19,7 +19,12 @@ import { updateNodePool, } from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import { getAll } from 'src/utilities/getAll'; @@ -101,7 +106,7 @@ export const useKubernetesClustersQuery = ( return useQuery, APIError[]>({ ...kubernetesQueries.lists._ctx.paginated(params, filter), enabled, - keepPreviousData: true, + placeholderData: keepPreviousData, }); }; @@ -127,7 +132,7 @@ export const useKubernetesClusterMutation = (id: number) => { export const useAllKubernetesClusterAPIEndpointsQuery = (id: number) => { return useQuery({ ...kubernetesQueries.cluster(id)._ctx.endpoints, - keepPreviousData: true, + placeholderData: keepPreviousData, refetchOnMount: true, retry: true, retryDelay: 5000, diff --git a/packages/manager/src/queries/linodes/linodes.ts b/packages/manager/src/queries/linodes/linodes.ts index 6adb643b5c5..9d33a64984a 100644 --- a/packages/manager/src/queries/linodes/linodes.ts +++ b/packages/manager/src/queries/linodes/linodes.ts @@ -25,6 +25,7 @@ import { } from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { + keepPreviousData, useInfiniteQuery, useMutation, useQuery, @@ -128,7 +129,7 @@ export const linodeQueries = createQueryKeys('linodes', { }), infinite: (filter: Filter = {}) => ({ queryFn: ({ pageParam }) => - getLinodes({ page: pageParam, page_size: 25 }, filter), + getLinodes({ page: pageParam as number, page_size: 25 }, filter), queryKey: [filter], }), paginated: (params: Params = {}, filter: Filter = {}) => ({ @@ -162,7 +163,7 @@ export const useLinodesQuery = ( ...linodeQueries.linodes._ctx.paginated(params, filter), ...queryPresets.longLived, enabled, - keepPreviousData: true, + placeholderData: keepPreviousData, }); }; @@ -187,6 +188,7 @@ export const useInfiniteLinodesQuery = (filter: Filter = {}) => } return page + 1; }, + initialPageParam: 1, }); export const useLinodeQuery = (id: number, enabled = true) => { @@ -429,9 +431,9 @@ export const useShutdownLinodeMutation = (id: number) => { }; export const useLinodeChangePasswordMutation = (id: number) => - useMutation<{}, APIError[], { root_pass: string }>(({ root_pass }) => - changeLinodePassword(id, root_pass) - ); + useMutation<{}, APIError[], { root_pass: string }>({ + mutationFn: ({ root_pass }) => changeLinodePassword(id, root_pass), + }); export const useLinodeMigrateMutation = (id: number) => { const queryClient = useQueryClient(); diff --git a/packages/manager/src/queries/nodebalancers.ts b/packages/manager/src/queries/nodebalancers.ts index bbfe635ad9e..4c68a85b393 100644 --- a/packages/manager/src/queries/nodebalancers.ts +++ b/packages/manager/src/queries/nodebalancers.ts @@ -14,6 +14,7 @@ import { } from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { + keepPreviousData, useInfiniteQuery, useMutation, useQuery, @@ -83,7 +84,10 @@ export const nodebalancerQueries = createQueryKeys('nodebalancers', { }, infinite: (filter: Filter = {}) => ({ queryFn: ({ pageParam }) => - getNodeBalancers({ page: pageParam, page_size: 25 }, filter), + getNodeBalancers( + { page: pageParam as number, page_size: 25 }, + filter + ), queryKey: [filter], }), paginated: (params: Params = {}, filter: Filter = {}) => ({ @@ -110,7 +114,7 @@ export const useNodeBalancerStatsQuery = (id: number) => { export const useNodeBalancersQuery = (params: Params, filter: Filter) => useQuery, APIError[]>({ ...nodebalancerQueries.nodebalancers._ctx.paginated(params, filter), - keepPreviousData: true, + placeholderData: keepPreviousData, }); export const useNodeBalancerQuery = (id: number, enabled = true) => @@ -288,6 +292,7 @@ export const useInfiniteNodebalancersQuery = (filter: Filter) => } return page + 1; }, + initialPageParam: 1, }); export const useNodeBalancersFirewallsQuery = (nodebalancerId: number) => diff --git a/packages/manager/src/queries/object-storage/queries.ts b/packages/manager/src/queries/object-storage/queries.ts index 11a882f2684..83988de1cd3 100644 --- a/packages/manager/src/queries/object-storage/queries.ts +++ b/packages/manager/src/queries/object-storage/queries.ts @@ -12,6 +12,7 @@ import { } from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { + keepPreviousData, useInfiniteQuery, useMutation, useQuery, @@ -175,7 +176,7 @@ export const useObjectStorageBuckets = (enabled = true) => { export const useObjectStorageAccessKeys = (params: Params) => useQuery, APIError[]>({ ...objectStorageQueries.accessKeys(params), - keepPreviousData: true, + placeholderData: keepPreviousData, }); export const useCreateBucketMutation = () => { @@ -274,11 +275,12 @@ export const useObjectBucketObjectsInfiniteQuery = ( ) => useInfiniteQuery({ getNextPageParam: (lastPage) => lastPage.next_marker, + initialPageParam: undefined, queryFn: ({ pageParam }) => getObjectList({ bucket, clusterId, - params: { delimiter, marker: pageParam, prefix }, + params: { delimiter, marker: pageParam as string | undefined, prefix }, }), queryKey: [ ...objectStorageQueries.bucket(clusterId, bucket)._ctx.objects.queryKey, diff --git a/packages/manager/src/queries/placementGroups.ts b/packages/manager/src/queries/placementGroups.ts index a2489faf163..f63f80ea927 100644 --- a/packages/manager/src/queries/placementGroups.ts +++ b/packages/manager/src/queries/placementGroups.ts @@ -8,7 +8,12 @@ import { updatePlacementGroup, } from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import { getAll } from 'src/utilities/getAll'; @@ -73,7 +78,7 @@ export const usePlacementGroupsQuery = ( ) => useQuery, APIError[]>({ enabled, - keepPreviousData: true, + placeholderData: keepPreviousData, ...placementGroupQueries.paginated(params, filter), }); diff --git a/packages/manager/src/queries/profile/profile.ts b/packages/manager/src/queries/profile/profile.ts index 726cd66b1cb..6d609e28902 100644 --- a/packages/manager/src/queries/profile/profile.ts +++ b/packages/manager/src/queries/profile/profile.ts @@ -1,7 +1,4 @@ import { - Profile, - SSHKey, - TrustedDevice, createSSHKey, deleteSSHKey, deleteTrustedDevice, @@ -22,14 +19,12 @@ import { } from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { - QueryClient, + keepPreviousData, useMutation, useQuery, useQueryClient, } from '@tanstack/react-query'; -import { EventHandlerData } from 'src/hooks/useEventHandlers'; - import { accountQueries } from '../account/queries'; import { queryPresets } from '../base'; @@ -38,11 +33,16 @@ import type { Filter, Grants, Params, + Profile, RequestOptions, ResourcePage, + SSHKey, SendPhoneVerificationCodePayload, + TrustedDevice, VerifyVerificationCodePayload, } from '@linode/api-v4'; +import type { QueryClient } from '@tanstack/react-query'; +import type { EventHandlerData } from 'src/hooks/useEventHandlers'; export const profileQueries = createQueryKeys('profile', { appTokens: (params: Params = {}, filter: Filter = {}) => ({ @@ -144,7 +144,7 @@ export const useSSHKeysQuery = ( useQuery, APIError[]>({ ...profileQueries.sshKeys(params, filter), enabled, - keepPreviousData: true, + placeholderData: keepPreviousData, }); export const useCreateSSHKeyMutation = () => { @@ -211,7 +211,7 @@ export const sshKeyEventHandler = ({ invalidateQueries }: EventHandlerData) => { export const useTrustedDevicesQuery = (params?: Params, filter?: Filter) => useQuery, APIError[]>({ ...profileQueries.trustedDevices(params, filter), - keepPreviousData: true, + placeholderData: keepPreviousData, }); export const useRevokeTrustedDeviceMutation = (id: number) => { diff --git a/packages/manager/src/queries/profile/tokens.ts b/packages/manager/src/queries/profile/tokens.ts index ef9ebd8da79..e2beb75cbdd 100644 --- a/packages/manager/src/queries/profile/tokens.ts +++ b/packages/manager/src/queries/profile/tokens.ts @@ -3,24 +3,30 @@ import { deleteAppToken, deletePersonalAccessToken, updatePersonalAccessToken, -} from '@linode/api-v4/lib/profile'; -import { Token, TokenRequest } from '@linode/api-v4/lib/profile/types'; +} from '@linode/api-v4'; import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; + +import { profileQueries } from './profile'; + +import type { APIError, Filter, Params, ResourcePage, -} from '@linode/api-v4/lib/types'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -import { EventHandlerData } from 'src/hooks/useEventHandlers'; - -import { profileQueries } from './profile'; + Token, + TokenRequest, +} from '@linode/api-v4'; +import type { EventHandlerData } from 'src/hooks/useEventHandlers'; export const useAppTokensQuery = (params?: Params, filter?: Filter) => { return useQuery, APIError[]>({ ...profileQueries.appTokens(params, filter), - keepPreviousData: true, + placeholderData: keepPreviousData, }); }; @@ -31,7 +37,7 @@ export const usePersonalAccessTokensQuery = ( ) => { return useQuery, APIError[]>({ enabled, - keepPreviousData: true, + placeholderData: keepPreviousData, ...profileQueries.personalAccessTokens(params, filter), }); }; @@ -62,7 +68,8 @@ export const useUpdatePersonalAccessTokenMutation = (id: number) => { export const useRevokePersonalAccessTokenMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => deletePersonalAccessToken(id), { + return useMutation<{}, APIError[]>({ + mutationFn: () => deletePersonalAccessToken(id), onSuccess() { // Wait 1 second to invalidate cache after deletion because API needs time setTimeout(() => { @@ -76,7 +83,8 @@ export const useRevokePersonalAccessTokenMutation = (id: number) => { export const useRevokeAppAccessTokenMutation = (id: number) => { const queryClient = useQueryClient(); - return useMutation<{}, APIError[]>(() => deleteAppToken(id), { + return useMutation<{}, APIError[]>({ + mutationFn: () => deleteAppToken(id), onSuccess() { // Wait 1 second to invalidate cache after deletion because API needs time setTimeout( diff --git a/packages/manager/src/queries/stackscripts.ts b/packages/manager/src/queries/stackscripts.ts index 42bb6de6138..8577428efba 100644 --- a/packages/manager/src/queries/stackscripts.ts +++ b/packages/manager/src/queries/stackscripts.ts @@ -24,7 +24,7 @@ export const getAllOCAsRequest = (passedParams: Params = {}) => export const stackscriptQueries = createQueryKeys('stackscripts', { infinite: (filter: Filter = {}) => ({ queryFn: ({ pageParam }) => - getStackScripts({ page: pageParam, page_size: 25 }, filter), + getStackScripts({ page: pageParam as number, page_size: 25 }, filter), queryKey: [filter], }), marketplace: { @@ -64,6 +64,7 @@ export const useStackScriptsInfiniteQuery = ( } return page + 1; }, + initialPageParam: 1, }); export const stackScriptEventHandler = ({ diff --git a/packages/manager/src/queries/statusPage/statusPage.ts b/packages/manager/src/queries/statusPage/statusPage.ts index aa20fdfda6d..241d134a55f 100644 --- a/packages/manager/src/queries/statusPage/statusPage.ts +++ b/packages/manager/src/queries/statusPage/statusPage.ts @@ -26,7 +26,7 @@ export const useIncidentQuery = () => }); export const useMaintenanceQuery = ( - options?: UseQueryOptions + options?: Partial> ) => useQuery({ ...statusPageQueries.maintenance, diff --git a/packages/manager/src/queries/support.ts b/packages/manager/src/queries/support.ts index 94524983762..a775d29f77d 100644 --- a/packages/manager/src/queries/support.ts +++ b/packages/manager/src/queries/support.ts @@ -12,6 +12,7 @@ import { } from '@linode/api-v4/lib/support'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { + keepPreviousData, useInfiniteQuery, useMutation, useQuery, @@ -32,7 +33,7 @@ const supportQueries = createQueryKeys('support', { contextQueries: { replies: { queryFn: ({ pageParam }) => - getTicketReplies(id, { page: pageParam, page_size: 25 }), + getTicketReplies(id, { page: pageParam as number, page_size: 25 }), queryKey: null, }, }, @@ -48,7 +49,7 @@ const supportQueries = createQueryKeys('support', { export const useSupportTicketsQuery = (params: Params, filter: Filter) => useQuery, APIError[]>({ ...supportQueries.tickets(params, filter), - keepPreviousData: true, + placeholderData: keepPreviousData, }); export const useSupportTicketQuery = (id: number) => @@ -72,6 +73,7 @@ export const useCreateSupportTicketMutation = () => { export const useInfiniteSupportTicketRepliesQuery = (id: number) => useInfiniteQuery, APIError[]>({ ...supportQueries.ticket(id)._ctx.replies, + initialPageParam: 1, getNextPageParam: ({ page, pages }) => { if (page === pages) { return undefined; diff --git a/packages/manager/src/queries/tags.ts b/packages/manager/src/queries/tags.ts index 5bbf110fce8..ae2edbd382e 100644 --- a/packages/manager/src/queries/tags.ts +++ b/packages/manager/src/queries/tags.ts @@ -1,23 +1,29 @@ -import { Tag, getTags } from '@linode/api-v4'; -import { APIError, Filter, Params } from '@linode/api-v4/lib/types'; -import { QueryClient, useQuery } from '@tanstack/react-query'; +import { getTags } from '@linode/api-v4'; +import { createQueryKeys } from '@lukemorales/query-key-factory'; +import { useQuery } from '@tanstack/react-query'; import { getAll } from 'src/utilities/getAll'; import { queryPresets } from './base'; -export const queryKey = 'tags'; +import type { APIError, Filter, Params, Tag } from '@linode/api-v4'; +import type { QueryClient } from '@tanstack/react-query'; -export const useTagSuggestions = (enabled = true) => - useQuery([queryKey], () => getAllTagSuggestions(), { +const tagQueries = createQueryKeys('tags', { + all: { + queryFn: () => getAllTags(), + queryKey: null, + }, +}); + +export const useAllTagsQuery = (enabled = true) => + useQuery({ + ...tagQueries.all, ...queryPresets.longLived, enabled, }); -const getAllTagSuggestions = ( - passedParams: Params = {}, - passedFilter: Filter = {} -) => +const getAllTags = (passedParams: Params = {}, passedFilter: Filter = {}) => getAll((params, filter) => getTags({ ...params, ...passedParams }, { ...filter, ...passedFilter }) )().then((data) => data.data); @@ -29,5 +35,5 @@ export const updateTagsSuggestionsData = ( const uniqueTags = Array.from(new Set(newData.map((tag) => tag.label))) .sort() .map((label) => ({ label })); - queryClient.setQueryData([queryKey], uniqueTags); + queryClient.setQueryData(tagQueries.all.queryKey, uniqueTags); }; diff --git a/packages/manager/src/queries/vlans.ts b/packages/manager/src/queries/vlans.ts index 6f21b3edf80..f9f6b7c6ef5 100644 --- a/packages/manager/src/queries/vlans.ts +++ b/packages/manager/src/queries/vlans.ts @@ -16,7 +16,7 @@ export const vlanQueries = createQueryKeys('vlans', { }, infinite: (filter: Filter = {}) => ({ queryFn: ({ pageParam = 1 }) => - getVlans({ page: pageParam, page_size: 25 }, filter), + getVlans({ page: pageParam as number, page_size: 25 }, filter), queryKey: [filter], }), }); @@ -33,7 +33,7 @@ export const useVLANsInfiniteQuery = (filter: Filter = {}, enabled = true) => { } return page + 1; }, - keepPreviousData: true, + initialPageParam: 1, ...vlanQueries.infinite(filter), enabled, }); diff --git a/packages/manager/src/queries/volumes/volumes.ts b/packages/manager/src/queries/volumes/volumes.ts index 0476d622e1d..85d48364753 100644 --- a/packages/manager/src/queries/volumes/volumes.ts +++ b/packages/manager/src/queries/volumes/volumes.ts @@ -20,6 +20,7 @@ import { APIError, ResourcePage } from '@linode/api-v4/lib/types'; import { Filter, Params, PriceType } from '@linode/api-v4/src/types'; import { createQueryKeys } from '@lukemorales/query-key-factory'; import { + keepPreviousData, useInfiniteQuery, useMutation, useQuery, @@ -49,7 +50,7 @@ export const volumeQueries = createQueryKeys('volumes', { }), infinite: (filter: Filter = {}) => ({ queryFn: ({ pageParam }) => - getVolumes({ page: pageParam, page_size: 25 }, filter), + getVolumes({ page: pageParam as number, page_size: 25 }, filter), queryKey: [filter], }), paginated: (params: Params = {}, filter: Filter = {}) => ({ @@ -68,7 +69,7 @@ export const volumeQueries = createQueryKeys('volumes', { export const useVolumesQuery = (params: Params, filter: Filter) => useQuery, APIError[]>({ ...volumeQueries.lists._ctx.paginated(params, filter), - keepPreviousData: true, + placeholderData: keepPreviousData, }); export const useVolumeTypesQuery = () => @@ -86,6 +87,7 @@ export const useInfiniteVolumesQuery = (filter: Filter) => } return page + 1; }, + initialPageParam: 1, }); export const useAllVolumesQuery = ( @@ -107,7 +109,7 @@ export const useLinodeVolumesQuery = ( useQuery, APIError[]>({ ...volumeQueries.linode(linodeId)._ctx.volumes(params, filter), enabled, - keepPreviousData: true, + placeholderData: keepPreviousData, }); interface ResizeVolumePayloadWithId extends ResizeVolumePayload { diff --git a/packages/manager/src/queries/vpcs/vpcs.ts b/packages/manager/src/queries/vpcs/vpcs.ts index 4c9909613c8..817127e249d 100644 --- a/packages/manager/src/queries/vpcs/vpcs.ts +++ b/packages/manager/src/queries/vpcs/vpcs.ts @@ -17,7 +17,12 @@ import { updateVPC, } from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import { getAllVPCsRequest } from './requests'; @@ -73,7 +78,7 @@ export const useVPCsQuery = ( return useQuery, APIError[]>({ ...vpcQueries.paginated(params, filter), enabled, - keepPreviousData: true, + placeholderData: keepPreviousData, }); }; @@ -123,7 +128,9 @@ export const useDeleteVPCMutation = (id: number) => { queryClient.invalidateQueries({ queryKey: vpcQueries.paginated._def, }); - queryClient.removeQueries(vpcQueries.vpc(id).queryKey); + queryClient.removeQueries({ + queryKey: vpcQueries.vpc(id).queryKey, + }); }, }); }; @@ -138,7 +145,7 @@ export const useSubnetsQuery = ( useQuery, APIError[]>({ ...vpcQueries.vpc(vpcId)._ctx.subnets._ctx.paginated(params, filter), enabled, - keepPreviousData: true, + placeholderData: keepPreviousData, }); export const useCreateSubnetMutation = (vpcId: number) => { diff --git a/yarn.lock b/yarn.lock index 333b005a083..9e5d35920ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3498,34 +3498,29 @@ resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.5.tgz#043b731d4f56a79b4897a3de1af35e75d56bc63a" integrity sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw== -"@tanstack/match-sorter-utils@^8.7.0": - version "8.11.8" - resolved "https://registry.yarnpkg.com/@tanstack/match-sorter-utils/-/match-sorter-utils-8.11.8.tgz#9132c2a21cf18ca2f0071b604ddadb7a66e73367" - integrity sha512-3VPh0SYMGCa5dWQEqNab87UpCMk+ANWHDP4ALs5PeEW9EpfTAbrezzaOk/OiM52IESViefkoAOYuxdoa04p6aA== - dependencies: - remove-accents "0.4.2" +"@tanstack/query-core@5.51.24": + version "5.51.24" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.51.24.tgz#da901637c8652ba5703b92bd07496e7c9ae27836" + integrity sha512-qtIR0FMHUDIWyIQw87q4C+so7XaN59MsGfWrc6rgi2VTHrVZF3Hd0St2dbpqRetHf6XW5yY5lzTrXpTilPlxUg== -"@tanstack/query-core@4.36.1": - version "4.36.1" - resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.36.1.tgz#79f8c1a539d47c83104210be2388813a7af2e524" - integrity sha512-DJSilV5+ytBP1FbFcEJovv4rnnm/CokuVvrBEtW/Va9DvuJ3HksbXUJEpI0aV1KtuL4ZoO9AVE6PyNLzF7tLeA== +"@tanstack/query-devtools@5.51.16": + version "5.51.16" + resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.51.16.tgz#d855d00e7939c1a442c2e8ae3ad1a5bd603d003b" + integrity sha512-ajwuq4WnkNCMj/Hy3KR8d3RtZ6PSKc1dD2vs2T408MdjgKzQ3klVoL6zDgVO7X+5jlb5zfgcO3thh4ojPhfIaw== -"@tanstack/react-query-devtools@4.36.1": - version "4.36.1" - resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-4.36.1.tgz#7e63601135902a993ca9af73507b125233b1554e" - integrity sha512-WYku83CKP3OevnYSG8Y/QO9g0rT75v1om5IvcWUwiUZJ4LanYGLVCZ8TdFG5jfsq4Ej/lu2wwDAULEUnRIMBSw== +"@tanstack/react-query-devtools@5.51.24": + version "5.51.24" + resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.51.24.tgz#f427b3569c30a803f494c3f7247760c12f379a62" + integrity sha512-tuEUUr8+ISdkM+tpYlBq2RsBIQ9RQvlZSGizyn4l3MR0hl3Pv8WBFbmOwzQZ1vtec1fa8DJ09SUgeQG1PnARog== dependencies: - "@tanstack/match-sorter-utils" "^8.7.0" - superjson "^1.10.0" - use-sync-external-store "^1.2.0" + "@tanstack/query-devtools" "5.51.16" -"@tanstack/react-query@4.36.1": - version "4.36.1" - resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.36.1.tgz#acb589fab4085060e2e78013164868c9c785e5d2" - integrity sha512-y7ySVHFyyQblPl3J3eQBWpXZkliroki3ARnBKsdJchlgt7yJLRDUcf4B8soufgiYt3pEQIkBWBx1N9/ZPIeUWw== +"@tanstack/react-query@5.51.24": + version "5.51.24" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.51.24.tgz#11385bee6b83a2d0ed6d5d03e16aab1cdf2cc24f" + integrity sha512-sW1qRwoCDqOFku67xng4Y5z6NPK1DS347jR4RiX9wFHrmyqpbXgUjPIjT3fodezdJAaSJD/6CvWb0cl05J8zNQ== dependencies: - "@tanstack/query-core" "4.36.1" - use-sync-external-store "^1.2.0" + "@tanstack/query-core" "5.51.24" "@testing-library/cypress@^10.0.2": version "10.0.2" @@ -5841,13 +5836,6 @@ cookie@^0.5.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== -copy-anything@^3.0.2: - version "3.0.5" - resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-3.0.5.tgz#2d92dce8c498f790fa7ad16b01a1ae5a45b020a0" - integrity sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w== - dependencies: - is-what "^4.1.8" - copy-to-clipboard@^3.0.8, copy-to-clipboard@^3.3.1: version "3.3.3" resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz#55ac43a1db8ae639a4bd99511c148cdd1b83a1b0" @@ -8658,11 +8646,6 @@ is-weakset@^2.0.1: call-bind "^1.0.2" get-intrinsic "^1.1.1" -is-what@^4.1.8: - version "4.1.16" - resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.16.tgz#1ad860a19da8b4895ad5495da3182ce2acdd7a6f" - integrity sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A== - is-wsl@^2.1.1, is-wsl@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" @@ -11539,11 +11522,6 @@ remark-stringify@^11.0.0: mdast-util-to-markdown "^2.0.0" unified "^11.0.0" -remove-accents@0.4.2: - version "0.4.2" - resolved "https://registry.yarnpkg.com/remove-accents/-/remove-accents-0.4.2.tgz#0a43d3aaae1e80db919e07ae254b285d9e1c7bb5" - integrity sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA== - req-all@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/req-all/-/req-all-1.0.0.tgz#d128569451c340b432409c656cf166260cd2628d" @@ -12581,13 +12559,6 @@ sucrase@^3.35.0: pirates "^4.0.1" ts-interface-checker "^0.1.9" -superjson@^1.10.0: - version "1.13.3" - resolved "https://registry.yarnpkg.com/superjson/-/superjson-1.13.3.tgz#3bd64046f6c0a47062850bb3180ef352a471f930" - integrity sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg== - dependencies: - copy-anything "^3.0.2" - supports-color@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.5.0.tgz#be7a0de484dec5c5cddf8b3d59125044912f635b" @@ -13285,11 +13256,6 @@ use-memo-one@^1.1.1: resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.3.tgz#2fd2e43a2169eabc7496960ace8c79efef975e99" integrity sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ== -use-sync-external-store@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" - integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== - util-deprecate@^1.0.1, util-deprecate@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" From 579d4181013a189080b3f23a326101aff631d9bb Mon Sep 17 00:00:00 2001 From: Hana Xu <115299789+hana-linode@users.noreply.github.com> Date: Thu, 29 Aug 2024 17:53:21 -0400 Subject: [PATCH 03/67] change: [M3-8379] - Disable Region in OS tab for unsupported distributed images (#10848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description 📝 Not all Linux Distributions in the Linode Create OS tab support distributed regions. We want to disable a region if the selected OS does not have a distributed capability similar to the Images tab ## Changes 🔄 - Remove specific check for `linodeCreateTab` in `getDisabledRegions` so that any selected image that does not have the `distributed-sites` capability is disabled - Minor UI positioning fix for distributed helper text w/ region error text ## How to test 🧪 ### Prerequisites (How to setup test environment) - Ensure your account has the `new-dc-testing`, `new-dc-testing-gecko`, `edge_testing` and `edge_compute` customer tags - Pull this PR and run it locally pointing to dev API ### Reproduction steps (How to reproduce the issue, if applicable) - Checkout `develop` locally while pointing to the dev API - Go to the Linode Create flow - Notice that distributed regions are not disabled for unsupported distributed images (no distributed icon, e.g. `Debian 11`) ### Verification steps (How to verify changes) - Go to the Linode Create flow and verify the `v1` and `v2` flows for Gecko `Beta` & Gecko `LA/GA` - To test Gecko Beta locally, you can add the following to `useFlags.ts`: - We do not want to change the LD flag globally since other teams rely on it - ...mockFlags, gecko2: { enabled: true, ga: false } - Verify unsupported distributed regions (no distributed icon, e.g. `Debian 11`) - Gecko LA/GA: Regions in the Distributed tab should be disabled with tooltip - Gecko Beta: Distributed regions in the single dropdown should be disabled with tooltip - Verify supported distributed regions (distributed icon, e.g. `Ubuntu 24.04 LTS`) - Gecko LA/GA: Regions in the Distributed tab should not be disabled - Gecko Beta: Distributed regions in the single dropdown should not be disabled --- .../.changeset/pr-10848-changed-1724869290917.md | 5 +++++ .../components/RegionSelect/RegionSelect.styles.ts | 12 +++++++----- .../src/components/RegionSelect/RegionSelect.tsx | 2 +- .../SelectRegionPanel/SelectRegionPanel.tsx | 1 - .../src/features/Linodes/LinodeCreatev2/Region.tsx | 1 - .../Linodes/LinodeCreatev2/Region.utils.test.ts | 2 -- .../features/Linodes/LinodeCreatev2/Region.utils.ts | 7 ++----- 7 files changed, 15 insertions(+), 15 deletions(-) create mode 100644 packages/manager/.changeset/pr-10848-changed-1724869290917.md diff --git a/packages/manager/.changeset/pr-10848-changed-1724869290917.md b/packages/manager/.changeset/pr-10848-changed-1724869290917.md new file mode 100644 index 00000000000..4ddd737ac9b --- /dev/null +++ b/packages/manager/.changeset/pr-10848-changed-1724869290917.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Disable Region in OS tab for unsupported distributed images and fix helper text positioning ([#10848](https://github.com/linode/manager/pull/10848)) diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts index 1d53b231832..088908408c6 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts +++ b/packages/manager/src/components/RegionSelect/RegionSelect.styles.ts @@ -45,24 +45,26 @@ export const sxDistributedRegionIcon = { export const StyledDistributedRegionBox = styled(Box, { label: 'StyledDistributedRegionBox', -})(({ theme }) => ({ + shouldForwardProp: (prop) => prop != 'centerChildren', +})<{ centerChildren: boolean }>(({ centerChildren, theme }) => ({ '& svg': { height: 21, marginLeft: 8, marginRight: 8, width: 24, }, - alignSelf: 'end', + alignSelf: centerChildren ? 'center' : 'end', color: 'inherit', display: 'flex', - marginLeft: 8, - padding: '8px 0', + marginTop: centerChildren ? 21 : 0, + padding: 8, [theme.breakpoints.down('md')]: { '& svg': { marginLeft: 0, }, alignSelf: 'start', - marginLeft: 0, + marginTop: 0, + paddingLeft: 0, }, })); diff --git a/packages/manager/src/components/RegionSelect/RegionSelect.tsx b/packages/manager/src/components/RegionSelect/RegionSelect.tsx index bb0c09af438..0eef45c778e 100644 --- a/packages/manager/src/components/RegionSelect/RegionSelect.tsx +++ b/packages/manager/src/components/RegionSelect/RegionSelect.tsx @@ -167,7 +167,7 @@ export const RegionSelect = < value={selectedRegion as Region} /> {showDistributedRegionIconHelperText && ( // @TODO Gecko Beta: Add docs link - + { ); const disabledRegions = getDisabledRegions({ - linodeCreateTab: params.type, regions: regions ?? [], selectedImage: image, }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx index 44974328513..a52a7caf06b 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.tsx @@ -187,7 +187,6 @@ export const Region = () => { ); const disabledRegions = getDisabledRegions({ - linodeCreateTab: params.type, regions: regions ?? [], selectedImage: image, }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.test.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.test.ts index 18059e73742..cfc1ef5db54 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.test.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.test.ts @@ -10,7 +10,6 @@ describe('getDisabledRegions', () => { const image = imageFactory.build({ capabilities: [] }); const result = getDisabledRegions({ - linodeCreateTab: 'Images', regions: [distributedRegion, coreRegion], selectedImage: image, }); @@ -30,7 +29,6 @@ describe('getDisabledRegions', () => { const image = imageFactory.build({ capabilities: ['distributed-sites'] }); const result = getDisabledRegions({ - linodeCreateTab: 'Images', regions: [distributedRegion, coreRegion], selectedImage: image, }); diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.ts index 54dc6d84412..cf81a46a69f 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Region.utils.ts @@ -1,9 +1,7 @@ -import type { LinodeCreateType } from '../LinodesCreate/types'; import type { Image, Region } from '@linode/api-v4'; import type { DisableRegionOption } from 'src/components/RegionSelect/RegionSelect.types'; interface DisabledRegionOptions { - linodeCreateTab: LinodeCreateType | undefined; regions: Region[]; selectedImage: Image | undefined; } @@ -14,13 +12,12 @@ interface DisabledRegionOptions { * @returns key/value pairs for disabled regions. the key is the region id and the value is why the region is disabled */ export const getDisabledRegions = (options: DisabledRegionOptions) => { - const { linodeCreateTab, regions, selectedImage } = options; + const { regions, selectedImage } = options; - // On the images tab, we disabled distributed regions if: + // Disable distributed regions if: // - The user has selected an Image // - The selected image does not have the `distributed-sites` capability if ( - linodeCreateTab === 'Images' && selectedImage && !selectedImage.capabilities.includes('distributed-sites') ) { From 29f14028e618c8e66507de4a7f62fab0e3afb2fb Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Fri, 30 Aug 2024 09:28:06 -0400 Subject: [PATCH 04/67] tech story: [M3-8385] - Replace lodash set utility function to handle prototype pollution security threat (#10814) * remove usage of omit that depends on lodash * it is not going * need to figure out types * hopefully this is something * todo: fix all bugs and write all the tests * write tests - need to debug * determine if supposed index is actually a valid index * some path indexing that seems difficult to pursue * update naming, tests * remove set from package.json * add back in some commented out tests * some cleanup, need to look over everything * update test cases and comments * changeset + update tests * simplify set, remove separate set utility files * Update packages/manager/src/utilities/formikErrorUtils.ts Co-authored-by: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> * update types + fix tests @abailly-akamai --------- Co-authored-by: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> --- .../pr-10814-tech-stories-1724434847753.md | 5 + packages/manager/package.json | 2 - .../Linodes/LinodeCreatev2/utilities.ts | 4 +- .../src/utilities/formikErrorUtils.test.ts | 215 ++++++++++++++++-- .../manager/src/utilities/formikErrorUtils.ts | 62 ++++- yarn.lock | 12 - 6 files changed, 270 insertions(+), 30 deletions(-) create mode 100644 packages/manager/.changeset/pr-10814-tech-stories-1724434847753.md diff --git a/packages/manager/.changeset/pr-10814-tech-stories-1724434847753.md b/packages/manager/.changeset/pr-10814-tech-stories-1724434847753.md new file mode 100644 index 00000000000..cbe20d384c3 --- /dev/null +++ b/packages/manager/.changeset/pr-10814-tech-stories-1724434847753.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Replace lodash set utility function to handle security threat raised by Dependabot ([#10814](https://github.com/linode/manager/pull/10814)) diff --git a/packages/manager/package.json b/packages/manager/package.json index 0610dbe4518..22afe983256 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -49,7 +49,6 @@ "libphonenumber-js": "^1.10.6", "lodash.clonedeep": "^4.5.0", "lodash.curry": "^4.1.1", - "lodash.set": "^4.3.2", "logic-query-parser": "^0.0.5", "luxon": "3.4.4", "markdown-it": "^12.3.2", @@ -143,7 +142,6 @@ "@types/jspdf": "^1.3.3", "@types/lodash.clonedeep": "^4.5.9", "@types/lodash.curry": "^4.1.9", - "@types/lodash.set": "^4.3.9", "@types/luxon": "3.4.2", "@types/markdown-it": "^10.0.2", "@types/md5": "^2.1.32", diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts index 631d6f417cb..5679f43bc84 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/utilities.ts @@ -1,4 +1,3 @@ -import { omit } from 'lodash'; import { useCallback } from 'react'; import { useHistory } from 'react-router-dom'; @@ -9,6 +8,7 @@ import { sendCreateLinodeEvent } from 'src/utilities/analytics/customEventAnalyt import { sendLinodeCreateFormErrorEvent } from 'src/utilities/analytics/formEventAnalytics'; import { privateIPRegex } from 'src/utilities/ipUtils'; import { isNotNullOrUndefined } from 'src/utilities/nullOrUndefined'; +import { omitProps } from 'src/utilities/omittedProps'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; import { utoa } from '../LinodesCreate/utilities'; @@ -148,7 +148,7 @@ export const tabs: LinodeCreateType[] = [ export const getLinodeCreatePayload = ( formValues: LinodeCreateFormValues ): CreateLinodeRequest => { - const values = omit(formValues, [ + const values = omitProps(formValues, [ 'linode', 'hasSignedEUAgreement', 'firewallOverride', diff --git a/packages/manager/src/utilities/formikErrorUtils.test.ts b/packages/manager/src/utilities/formikErrorUtils.test.ts index 3b7df4ce74b..48adf78a959 100644 --- a/packages/manager/src/utilities/formikErrorUtils.test.ts +++ b/packages/manager/src/utilities/formikErrorUtils.test.ts @@ -2,6 +2,7 @@ import { getFormikErrorsFromAPIErrors, handleAPIErrors, handleVPCAndSubnetErrors, + set, } from './formikErrorUtils'; const errorWithoutField = [{ reason: 'Internal server error' }]; @@ -35,51 +36,51 @@ describe('handleAPIErrors', () => { const subnetMultipleErrorsPerField = [ { - reason: 'not expected error for label', field: 'subnets[0].label', + reason: 'not expected error for label', }, { - reason: 'expected error for label', field: 'subnets[0].label', + reason: 'expected error for label', }, { - reason: 'not expected error for label', field: 'subnets[3].label', + reason: 'not expected error for label', }, { - reason: 'expected error for label', field: 'subnets[3].label', + reason: 'expected error for label', }, { - reason: 'not expected error for ipv4', field: 'subnets[3].ipv4', + reason: 'not expected error for ipv4', }, { - reason: 'expected error for ipv4', field: 'subnets[3].ipv4', + reason: 'expected error for ipv4', }, ]; const subnetErrors = [ { - reason: 'Label required', field: 'subnets[1].label', + reason: 'Label required', }, { - reason: 'bad label', field: 'subnets[2].label', + reason: 'bad label', }, { - reason: 'cidr ipv4', field: 'subnets[2].ipv4', + reason: 'cidr ipv4', }, { - reason: 'needs an ip', field: 'subnets[4].ipv4', + reason: 'needs an ip', }, { - reason: 'needs an ipv6', field: 'subnets[4].ipv6', + reason: 'needs an ipv6', }, ]; @@ -93,7 +94,7 @@ describe('handleVpcAndConvertSubnetErrors', () => { expect(Object.keys(errors)).toHaveLength(3); expect(Object.keys(errors)).toEqual(['1', '2', '4']); expect(errors[1]).toEqual({ label: 'Label required' }); - expect(errors[2]).toEqual({ label: 'bad label', ipv4: 'cidr ipv4' }); + expect(errors[2]).toEqual({ ipv4: 'cidr ipv4', label: 'bad label' }); expect(errors[4]).toEqual({ ipv4: 'needs an ip', ipv6: 'needs an ipv6' }); }); @@ -106,8 +107,8 @@ describe('handleVpcAndConvertSubnetErrors', () => { expect(Object.keys(errors)).toHaveLength(2); expect(errors[0]).toEqual({ label: 'expected error for label' }); expect(errors[3]).toEqual({ - label: 'expected error for label', ipv4: 'expected error for ipv4', + label: 'expected error for label', }); }); @@ -201,3 +202,191 @@ describe('getFormikErrorsFromAPIErrors', () => { } }); }); + +describe('Tests for set', () => { + it("returns the passed in 'object' as is if it's not actually a (non array) object", () => { + expect(set([], 'path not needed', '1')).toEqual([]); + }); + + describe('Correctly setting the value at the given path', () => { + it('sets the value for a simple path', () => { + const object = {}; + let settedObject = set(object, 'test', '1'); + expect(object).toBe(settedObject); + expect(object).toEqual({ test: '1' }); + + settedObject = set(object, 'test2', '1'); + expect(object).toBe(settedObject); + expect(object).toEqual({ test: '1', test2: '1' }); + }); + + it('sets the value for complex string paths (without indexes)', () => { + const object = {}; + + set(object, 'a.b.c', 'c'); + expect(object).toEqual({ a: { b: { c: 'c' } } }); + + set(object, 'a.b.d', 'd'); + expect(object).toEqual({ + a: { b: { c: 'c', d: 'd' } }, + }); + + set(object, 'e[f][g]', 'g'); + expect(object).toEqual({ + a: { b: { c: 'c', d: 'd' } }, + e: { f: { g: 'g' } }, + }); + }); + + it('sets the value for complex string paths (with indexes)', () => { + const object = {}; + + set(object, 'a.b.1', 'b1'); + expect(object).toEqual({ a: { b: [undefined, 'b1'] } }); + set(object, 'a.b[0]', '5'); + expect(object).toEqual({ a: { b: ['5', 'b1'] } }); + + set(object, 'a.b.2', 'b2'); + expect(object).toEqual({ + a: { b: ['5', 'b1', 'b2'] }, + }); + + set(object, 'a.b[3].c', 'c'); + expect(object).toEqual({ + a: { b: ['5', 'b1', 'b2', { c: 'c' }] }, + }); + }); + + it('only considers 0 or positive integers for setting array values', () => { + const object = {}; + + expect(set(object, 'test[-01].test1', 'test')).toEqual({ + test: { '-01': { test1: 'test' } }, + }); + expect(set(object, 'test[-01][-02]', 'test2')).toEqual({ + test: { '-01': { '-02': 'test2', test1: 'test' } }, + }); + expect(set(object, 'test[ 02]', 'test3')).toEqual({ + test: { + ' 02': 'test3', + '-01': { '-02': 'test2', test1: 'test' }, + }, + }); + expect(set(object, 'test[0 0]', 'test4')).toEqual({ + test: { + ' 02': 'test3', + '-01': { '-02': 'test2', test1: 'test' }, + '0 0': 'test4', + }, + }); + }); + + it('considers numbers as keys if they are not followed by another number', () => { + const object = {}; + set(object, '1', 'test'); + expect(object).toEqual({ 1: 'test' }); + + set(object, '2', '2'); + expect(object).toEqual({ 1: 'test', 2: '2' }); + }); + + it('treats numbers as array indexes if they precede some previous key (if they are valid integers >= 0)', () => { + const obj1 = set({}, '1[1]', 'test'); + expect(obj1).toEqual({ 1: [undefined, 'test'] }); + + const obj2 = set({}, '1.2', 'test'); + expect(obj2).toEqual({ 1: [undefined, undefined, 'test'] }); + }); + + it('can replace the value at an already existing key', () => { + const alreadyExisting = { test: 'test' }; + expect(set(alreadyExisting, 'test', 'changed')).toEqual({ + test: 'changed', + }); + expect(set(alreadyExisting, 'test[test2][test3]', 'changed x4')).toEqual({ + test: { test2: { test3: 'changed x4' } }, + }); + }); + + it('sets the value for nonstandard paths', () => { + expect(set({}, 'test.[.test]', 'testing 2')).toEqual({ + test: { test: 'testing 2' }, + }); + expect(set({}, 'test.[te[st]', 'testing 3')).toEqual({ + test: { te: { st: 'testing 3' } }, + }); + expect(set({}, 'test.]test', 'testing 4')).toEqual({ + test: { test: 'testing 4' }, + }); + }); + }); + + describe('Ensuring safety against prototype pollution and that the passed in and returned object are the same', () => { + it('protects against the given string path matching a prototype pollution key', () => { + const object = {}; + // __proto__ + let settedObject = set(object, '__proto__', '1'); + expect(object).toBe(settedObject); + expect(object).toEqual({}); + + // constructor + settedObject = set(object, 'constructor', '1'); + expect(object).toBe(settedObject); + expect(object).toEqual({}); + + // prototype + settedObject = set(object, 'prototype', '1'); + expect(object).toBe(settedObject); + expect(object).toEqual({}); + }); + + it('protects against the given string path containing prototype pollution keys that are separated by path delimiters', () => { + const object = {}; + // prototype pollution key separated by . + let settedObject = set(object, 'test.__proto__.test', '1'); + expect(object).toBe(settedObject); + expect(object).toEqual({}); + + settedObject = set(object, 'test.constructor.test', '1'); + expect(object).toBe(settedObject); + expect(object).toEqual({}); + + settedObject = set(object, 'test.prototype.test', '1'); + expect(object).toBe(settedObject); + expect(object).toEqual({}); + + // prototype pollution key separated by [] + settedObject = set(object, 'test.test[__proto__]', '1'); + expect(object).toBe(settedObject); + expect(object).toEqual({}); + + settedObject = set(object, 'test.test[constructor]', '1'); + expect(object).toBe(settedObject); + expect(object).toEqual({}); + + settedObject = set(object, 'test.test[prototype]', '1'); + expect(object).toBe(settedObject); + expect(object).toEqual({}); + }); + + it('is not considered prototype pollution if the string paths have a key not separated by delimiters', () => { + const object = {}; + // prototype pollution key separated by . + let settedObject = set(object, 'test__proto__test', '1'); + expect(object).toBe(settedObject); + expect(object).toEqual({ test__proto__test: '1' }); + + settedObject = set(object, 'constructortest', '1'); + expect(object).toBe(settedObject); + expect(object).toEqual({ constructortest: '1', test__proto__test: '1' }); + + settedObject = set(object, 'testprototype', '1'); + expect(object).toBe(settedObject); + expect(object).toEqual({ + constructortest: '1', + test__proto__test: '1', + testprototype: '1', + }); + }); + }); +}); diff --git a/packages/manager/src/utilities/formikErrorUtils.ts b/packages/manager/src/utilities/formikErrorUtils.ts index 3e4e14deea4..1f46a13d6ea 100644 --- a/packages/manager/src/utilities/formikErrorUtils.ts +++ b/packages/manager/src/utilities/formikErrorUtils.ts @@ -1,4 +1,3 @@ -import set from 'lodash.set'; import { reverse } from 'ramda'; import { getAPIErrorOrDefault } from './errorUtils'; @@ -23,6 +22,67 @@ export const getFormikErrorsFromAPIErrors = ( }, {}); }; +// regex used in the below set function +const onlyDigitsRegex = /^\d+$/; + +/** + * Helper for getFormikErrorsFromAPIErrors, sets the given value at a specified path of the given object. + * Note that while we are using this function in place of lodash's set, it is not an exact replacement. + * This method both mutates the passed in object and returns it. + * + * @param object — The object to modify. + * @param path — The path of the property to set. + * @param value — The value to set. + * @return — Returns object. + */ +export const set = ( + obj: FormikErrors, + path: string, + value: string +): FormikErrors => { + const parts = path.split(/\.|\[|\]/).filter(Boolean); + + // ensure that obj is not an array and that the path is prototype pollution safe + if (Array.isArray(obj) || !isPrototypePollutionSafe(parts)) { + return obj; + } + + parts.reduce((acc: Record, part: string, index: number) => { + if (index === parts.length - 1) { + // Last part, set the value + acc[part] = value; + } else if (part.match(onlyDigitsRegex)) { + // Handle array indices + const arrayIndex = parseInt(part, 10); + acc[arrayIndex] = + acc[arrayIndex] ?? (parts[index + 1].match(onlyDigitsRegex) ? [] : {}); + } else { + // Handle nested objects + const potentialNextVal = parts[index + 1].match(onlyDigitsRegex) + ? [] + : {}; + acc[part] = typeof acc[part] === 'object' ? acc[part] : potentialNextVal; + } + return acc[part]; + }, obj); + + return obj; +}; + +/** + * Ensures a path cannot lead to a prototype pollution issue. + * + * @param path - The path to check + * @return - boolean depending on whether the path is safe or not + */ +const isPrototypePollutionSafe = (path: string[]): boolean => { + return path.reduce((safeSoFar, val) => { + const isCurKeySafe = + val !== '__proto__' && val !== 'prototype' && val !== 'constructor'; + return safeSoFar && isCurKeySafe; + }, true); +}; + export const handleFieldErrors = ( callback: (error: unknown) => void, fieldErrors: APIError[] = [] diff --git a/yarn.lock b/yarn.lock index 9e5d35920ae..18ac5e1751a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3912,13 +3912,6 @@ dependencies: "@types/lodash" "*" -"@types/lodash.set@^4.3.9": - version "4.3.9" - resolved "https://registry.yarnpkg.com/@types/lodash.set/-/lodash.set-4.3.9.tgz#55d95bce407b42c6655f29b2d0811fd428e698f0" - integrity sha512-KOxyNkZpbaggVmqbpr82N2tDVTx05/3/j0f50Es1prxrWB0XYf9p3QNxqcbWb7P1Q9wlvsUSlCFnwlPCIJ46PQ== - dependencies: - "@types/lodash" "*" - "@types/lodash@*": version "4.17.0" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.0.tgz#d774355e41f372d5350a4d0714abb48194a489c3" @@ -9179,11 +9172,6 @@ lodash.once@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== -lodash.set@^4.3.2: - version "4.3.2" - resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23" - integrity sha512-4hNPN5jlm/N/HLMCO43v8BXKq9Z7QdAGc/VGrRD61w8gN9g/6jF9A4L1pbUgBLCffi0w9VsXfTOij5x8iTyFvg== - lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" From 59a4e9fae791ce8a2bc4b562bb0fb586a6117acd Mon Sep 17 00:00:00 2001 From: Connie Liu <139280159+coliu-akamai@users.noreply.github.com> Date: Fri, 30 Aug 2024 12:08:57 -0400 Subject: [PATCH 05/67] fix: [M3-7142] - Update helper text copy in NodeBalancer Create form 'Algorithm' field (#10855) * fix helper text * add tests * update test case descriptions * changesets * Update packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --------- Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- .../pr-10855-fixed-1724944515841.md | 5 + .../pr-10855-tests-1724944533420.md | 5 + .../NodeBalancers/NodeBalancerActiveCheck.tsx | 10 +- .../NodeBalancerConfigPanel.test.tsx | 263 ++++++++++++++++++ .../NodeBalancers/NodeBalancerConfigPanel.tsx | 54 ++-- .../src/features/NodeBalancers/types.ts | 4 +- 6 files changed, 313 insertions(+), 28 deletions(-) create mode 100644 packages/manager/.changeset/pr-10855-fixed-1724944515841.md create mode 100644 packages/manager/.changeset/pr-10855-tests-1724944533420.md create mode 100644 packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx diff --git a/packages/manager/.changeset/pr-10855-fixed-1724944515841.md b/packages/manager/.changeset/pr-10855-fixed-1724944515841.md new file mode 100644 index 00000000000..ac11040213a --- /dev/null +++ b/packages/manager/.changeset/pr-10855-fixed-1724944515841.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Helper text copy in NodeBalancer Create form 'Algorithm' field ([#10855](https://github.com/linode/manager/pull/10855)) diff --git a/packages/manager/.changeset/pr-10855-tests-1724944533420.md b/packages/manager/.changeset/pr-10855-tests-1724944533420.md new file mode 100644 index 00000000000..2f9497016c6 --- /dev/null +++ b/packages/manager/.changeset/pr-10855-tests-1724944533420.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add unit tests for NodeBalancerConfigPanel ([#10855](https://github.com/linode/manager/pull/10855)) diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx index 33e8cf66a97..ec43a2f3ed1 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerActiveCheck.tsx @@ -96,6 +96,9 @@ export const ActiveCheck = (props: ActiveCheckProps) => { + props.onHealthCheckTypeChange(selected.value) + } textFieldProps={{ dataAttrs: { 'data-qa-active-check-select': true, @@ -103,15 +106,12 @@ export const ActiveCheck = (props: ActiveCheckProps) => { errorGroup: forEdit ? `${configIdx}` : undefined, }} autoHighlight + disableClearable disabled={disabled} errorText={errorMap.check} id={`type-${configIdx}`} - disableClearable label="Type" noMarginTop - onChange={(_, selected) => - props.onHealthCheckTypeChange(selected.value) - } options={typeOptions} size="small" value={defaultType || typeOptions[0]} @@ -188,6 +188,7 @@ export const ActiveCheck = (props: ActiveCheckProps) => { {['http', 'http_body'].includes(healthCheckType) && ( { {healthCheckType === 'http_body' && ( { + vi.resetAllMocks(); +}); + +const node: NodeBalancerConfigNodeFields = { + address: '', + label: '', + mode: 'accept', + modifyStatus: 'new', + port: 80, + weight: 100, +}; + +const props: NodeBalancerConfigPanelProps = { + addNode: vi.fn(), + algorithm: 'roundrobin', + checkBody: '', + checkPassive: true, + checkPath: '', + configIdx: 0, + disabled: false, + healthCheckAttempts: 2, + healthCheckInterval: 5, + healthCheckTimeout: 3, + healthCheckType: 'none', + nodes: [node], + onAlgorithmChange: vi.fn(), + onCheckBodyChange: vi.fn(), + onCheckPassiveChange: vi.fn(), + onCheckPathChange: vi.fn(), + onDelete: vi.fn(), + onHealthCheckAttemptsChange: vi.fn(), + onHealthCheckIntervalChange: vi.fn(), + onHealthCheckTimeoutChange: vi.fn(), + onHealthCheckTypeChange: vi.fn(), + onNodeAddressChange: vi.fn(), + onNodeLabelChange: vi.fn(), + onNodePortChange: vi.fn(), + onNodeWeightChange: vi.fn(), + onPortChange: vi.fn(), + onPrivateKeyChange: vi.fn(), + onProtocolChange: vi.fn(), + onProxyProtocolChange: vi.fn(), + onSave: vi.fn(), + onSessionStickinessChange: vi.fn(), + onSslCertificateChange: vi.fn(), + port: 80, + privateKey: '', + protocol: 'http', + proxyProtocol: 'none', + removeNode: vi.fn(), + sessionStickiness: 'table', + sslCertificate: '', +}; + +const activeHealthChecks = ['Interval', 'Timeout', 'Attempts']; + +describe('NodeBalancerConfigPanel', () => { + it('renders the NodeBalancerConfigPanel', () => { + const { + getByLabelText, + getByText, + queryByLabelText, + queryByTestId, + } = renderWithTheme(); + + expect(getByLabelText('Protocol')).toBeVisible(); + expect(getByLabelText('Algorithm')).toBeVisible(); + expect(getByLabelText('Session Stickiness')).toBeVisible(); + expect(getByLabelText('Type')).toBeVisible(); + expect(getByLabelText('Label')).toBeVisible(); + expect(getByLabelText('IP Address')).toBeVisible(); + expect(getByLabelText('Weight')).toBeVisible(); + expect(getByLabelText('Port')).toBeVisible(); + expect(getByText('Listen on this port.')).toBeVisible(); + expect(getByText('Active Health Checks')).toBeVisible(); + expect( + getByText( + 'Route subsequent requests from the client to the same backend.' + ) + ).toBeVisible(); + expect( + getByText( + 'Enable passive checks based on observing communication with back-end nodes.' + ) + ).toBeVisible(); + expect( + getByText( + "Active health checks proactively check the health of back-end nodes. 'HTTP Valid Status' requires a 2xx or 3xx response from the backend node. 'HTTP Body Regex' uses a regex to match against an expected result body." + ) + ).toBeVisible(); + expect(getByText('Add a Node')).toBeVisible(); + expect(getByText('Backend Nodes')).toBeVisible(); + + activeHealthChecks.forEach((type) => { + expect(queryByLabelText(type)).not.toBeInTheDocument(); + }); + expect(queryByTestId('ssl-certificate')).not.toBeInTheDocument(); + expect(queryByTestId('private-key')).not.toBeInTheDocument(); + expect(queryByTestId('http-path')).not.toBeInTheDocument(); + expect(queryByTestId('http-body')).not.toBeInTheDocument(); + expect(queryByLabelText('Proxy Protocol')).not.toBeInTheDocument(); + }); + + it('renders form fields specific to the HTTPS protocol', () => { + const { getByTestId, queryByLabelText } = renderWithTheme( + + ); + + expect(getByTestId('ssl-certificate')).toBeVisible(); + expect(getByTestId('private-key')).toBeVisible(); + expect(queryByLabelText('Proxy Protocol')).not.toBeInTheDocument(); + }); + + it('renders form fields specific to the TCP protocol', () => { + const { getByLabelText, queryByTestId } = renderWithTheme( + + ); + + expect(getByLabelText('Proxy Protocol')).toBeVisible(); + expect(queryByTestId('ssl-certificate')).not.toBeInTheDocument(); + expect(queryByTestId('private-key')).not.toBeInTheDocument(); + }); + + it('renders fields specific to the Active Health Check type of TCP Connection', () => { + const { getByLabelText, queryByTestId } = renderWithTheme( + + ); + + activeHealthChecks.forEach((type) => { + expect(getByLabelText(type)).toBeVisible(); + }); + expect(queryByTestId('http-path')).not.toBeInTheDocument(); + expect(queryByTestId('http-body')).not.toBeInTheDocument(); + }); + + it('renders fields specific to the Active Health Check type of HTTP Status', () => { + const { getByLabelText, getByTestId, queryByTestId } = renderWithTheme( + + ); + + activeHealthChecks.forEach((type) => { + expect(getByLabelText(type)).toBeVisible(); + }); + expect(getByTestId('http-path')).toBeVisible(); + expect(queryByTestId('http-body')).not.toBeInTheDocument(); + }); + + it('renders fields specific to the Active Health Check type of HTTP Body', () => { + const { getByLabelText, getByTestId } = renderWithTheme( + + ); + + activeHealthChecks.forEach((type) => { + expect(getByLabelText(type)).toBeVisible(); + }); + expect(getByTestId('http-path')).toBeVisible(); + expect(getByTestId('http-body')).toBeVisible(); + }); + + it('renders the relevant helper text for the Round Robin algorithm', () => { + const { getByText, queryByText } = renderWithTheme( + + ); + + expect(getByText(ROUND_ROBIN_ALGORITHM_HELPER_TEXT)).toBeVisible(); + expect( + queryByText(LEAST_CONNECTIONS_ALGORITHM_HELPER_TEXT) + ).not.toBeInTheDocument(); + expect(queryByText(SOURCE_ALGORITHM_HELPER_TEXT)).not.toBeInTheDocument(); + }); + + it('renders the relevant helper text for the Least Connections algorithm', () => { + const { getByText, queryByText } = renderWithTheme( + + ); + + expect(getByText(LEAST_CONNECTIONS_ALGORITHM_HELPER_TEXT)).toBeVisible(); + expect(queryByText(SOURCE_ALGORITHM_HELPER_TEXT)).not.toBeInTheDocument(); + expect( + queryByText(ROUND_ROBIN_ALGORITHM_HELPER_TEXT) + ).not.toBeInTheDocument(); + }); + + it('renders the relevant helper text for the Source algorithm', () => { + const { getByText, queryByText } = renderWithTheme( + + ); + + expect(getByText(SOURCE_ALGORITHM_HELPER_TEXT)).toBeVisible(); + expect( + queryByText(ROUND_ROBIN_ALGORITHM_HELPER_TEXT) + ).not.toBeInTheDocument(); + expect( + queryByText(LEAST_CONNECTIONS_ALGORITHM_HELPER_TEXT) + ).not.toBeInTheDocument(); + }); + + it('adds another backend node', () => { + const { getByText } = renderWithTheme( + + ); + + const addNodeButton = getByText('Add a Node'); + fireEvent.click(addNodeButton); + expect(props.addNode).toHaveBeenCalled(); + }); + + it('cannot remove a backend node if there is only one node', () => { + const { queryByText } = renderWithTheme( + + ); + + expect(queryByText('Remove')).not.toBeInTheDocument(); + }); + + it('removes a backend node', () => { + const { getByText } = renderWithTheme( + + ); + + const removeNodeButton = getByText('Remove'); + fireEvent.click(removeNodeButton); + expect(props.removeNode).toHaveBeenCalled(); + }); + + it('deletes the configuration panel', () => { + const { getByText } = renderWithTheme( + + ); + + const deleteConfigButton = getByText('Delete'); + fireEvent.click(deleteConfigButton); + expect(props.onDelete).toHaveBeenCalled(); + }); + + it('saves the input after editing the configuration', () => { + const { getByText } = renderWithTheme( + + ); + + const editConfigButton = getByText('Save'); + fireEvent.click(editConfigButton); + expect(props.onSave).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx index 4f0afc9b0bd..a7b726c133c 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.tsx @@ -1,11 +1,11 @@ -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 { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Button } from 'src/components/Button/Button'; import { Divider } from 'src/components/Divider'; -import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { FormHelperText } from 'src/components/FormHelperText'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; @@ -21,6 +21,12 @@ import type { NodeBalancerConfigPanelProps } from './types'; import type { NodeBalancerConfigNodeMode } from '@linode/api-v4'; const DATA_NODE = 'data-node-idx'; +export const ROUND_ROBIN_ALGORITHM_HELPER_TEXT = + 'Round robin distributes connection requests to backend servers in weighted circular order.'; +export const LEAST_CONNECTIONS_ALGORITHM_HELPER_TEXT = + 'Least connections assigns connections to the backend with the least connections.'; +export const SOURCE_ALGORITHM_HELPER_TEXT = + "Source uses the client's IPv4 address."; export const NodeBalancerConfigPanel = ( props: NodeBalancerConfigPanelProps @@ -156,6 +162,12 @@ export const NodeBalancerConfigPanel = ( return eachAlg.value === algorithm; }); + const algorithmHelperText = { + leastconn: LEAST_CONNECTIONS_ALGORITHM_HELPER_TEXT, + roundrobin: ROUND_ROBIN_ALGORITHM_HELPER_TEXT, + source: SOURCE_ALGORITHM_HELPER_TEXT, + }; + const sessionOptions = [ { label: 'None', value: 'none' }, { label: 'Table', value: 'table' }, @@ -192,7 +204,7 @@ export const NodeBalancerConfigPanel = ( type="number" value={port || ''} /> - Listen on this port + Listen on this port. { + props.onProxyProtocolChange(selected.value); + }} textFieldProps={{ dataAttrs: { 'data-qa-proxy-protocol-select': true, @@ -259,15 +276,12 @@ export const NodeBalancerConfigPanel = ( }, }} autoHighlight + disableClearable disabled={disabled} errorText={errorMap.proxy_protocol} id={`proxy-protocol-${configIdx}`} - disableClearable label="Proxy Protocol" noMarginTop - onChange={(_, selected) => { - props.onProxyProtocolChange(selected.value); - }} options={proxyProtocolOptions} size="small" value={selectedProxyProtocol || proxyProtocolOptions[0]} @@ -286,6 +300,9 @@ export const NodeBalancerConfigPanel = ( { + props.onAlgorithmChange(selected.value); + }} textFieldProps={{ dataAttrs: { 'data-qa-algorithm-select': true, @@ -293,28 +310,24 @@ export const NodeBalancerConfigPanel = ( errorGroup: forEdit ? `${configIdx}` : undefined, }} autoHighlight + disableClearable disabled={disabled} errorText={errorMap.algorithm} id={`algorithm-${configIdx}`} - disableClearable label="Algorithm" noMarginTop - onChange={(_, selected) => { - props.onAlgorithmChange(selected.value); - }} options={algOptions} size="small" value={defaultAlg || algOptions[0]} /> - - Roundrobin. Least connections assigns connections to the backend - with the least connections. Source uses the client’s IPv4 - address - + {algorithmHelperText[algorithm]} { + props.onSessionStickinessChange(selected.value); + }} textFieldProps={{ dataAttrs: { 'data-qa-session-stickiness-select': true, @@ -322,21 +335,18 @@ export const NodeBalancerConfigPanel = ( errorGroup: forEdit ? `${configIdx}` : undefined, }} autoHighlight + disableClearable disabled={disabled} errorText={errorMap.stickiness} id={`session-stickiness-${configIdx}`} - disableClearable label="Session Stickiness" noMarginTop - onChange={(_, selected) => { - props.onSessionStickinessChange(selected.value); - }} options={sessionOptions} size="small" value={defaultSession || sessionOptions[1]} /> - Route subsequent requests from the client to the same backend + Route subsequent requests from the client to the same backend. diff --git a/packages/manager/src/features/NodeBalancers/types.ts b/packages/manager/src/features/NodeBalancers/types.ts index 756c2dbfe9b..1a4d465615e 100644 --- a/packages/manager/src/features/NodeBalancers/types.ts +++ b/packages/manager/src/features/NodeBalancers/types.ts @@ -1,8 +1,8 @@ -import { +import type { NodeBalancerConfigNodeMode, NodeBalancerProxyProtocol, } from '@linode/api-v4/lib/nodebalancers/types'; -import { APIError } from '@linode/api-v4/lib/types'; +import type { APIError } from '@linode/api-v4/lib/types'; export interface NodeBalancerConfigFieldsWithStatus extends NodeBalancerConfigFields { From 518a901d864e2eaacf3512e9228e69fdea4fb2b3 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Fri, 30 Aug 2024 12:51:15 -0400 Subject: [PATCH 06/67] Tech Story: [M3-8281] - Introduce Mock Service Worker v2 (#10610) * WIP add MSW preset selection * WIP * WIP * WIP dev tools restyle * WIP styling * WIP styling * WIP Allow presets and content to be set and loaded via dev tool * Add Production-like and Edge mock regions data * Add 'Backups' to APIv4 `Capabilities` type * Rename file * Allow presets to have a description * Add populators for region data * Add account review base preset * WIP * Improve styling of dev tool selects * Space between environment select and util buttons * WIP Improve MSW save handling * Slightly improve docs/function names * WIP Slightly improve mobile layout * WIP RQ * WIP React Query * Handle region availability requests * WIP event handling, Linode events * fix units * fixes & cleanup * More cleanup * UI improvements * placement group handlers part 1 * placement group handlers part 2 * placement group handlers part 3 * wrap up crud operations for PGs * fix hot reload bug * Revert "fix hot reload bug" This reverts commit 494cc4e78b5ffda7993e57188d8de4eb17781488. * fix hot reload issue the right way * completing data for initial cruds * working on events * events handling * store part 1 * update naming coventions * store part 2 * store part 3 * store part 4: seeds * store part 5: seeds * more seeding logic * regions * volumes * crud events part 1 * crud events part 2 * naming conventions - part 1 * naming conventions - part 2 * naming conventions - part 3 * naming conventions - part 4 * unique IDs and utils * pagination and sorting logic * light ServiceWorker tool * region work * worker tools updates * code cleanup 1 * code cleanup 2 * code cleanup 3 * code cleanup 4 * moar service worker tool work * some cleanup and more handlers * Documentatio part 1 * Documentatio part 2 * Small cleanup * Added changeset: Introduce Mock Service Worker V2 * Update docs/development-guide/09-mocking-data.md Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * Update docs/development-guide/09-mocking-data.md Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * Update docs/development-guide/09-mocking-data.md Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * Update docs/development-guide/09-mocking-data.md Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * Update docs/development-guide/09-mocking-data.md Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * TS lint fix * cleanup and feedback * Draggable dev tools * Address feedback * small cleanup and comments * file renaming and re-org * getRoot rename * fix scroll issue * fix loading in preview links * feedback @mjac0bs * cleanup * feedback @dwiley-akamai * Account for react query update * feedback @coliu-akamai --------- Co-authored-by: Joe D'Amore Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- docs/development-guide/09-mocking-data.md | 62 +- docs/development-guide/10-local-dev-tools.md | 4 +- packages/api-v4/src/regions/types.ts | 1 + .../pr-10610-tech-stories-1723046371666.md | 5 + packages/manager/.storybook/preview.tsx | 4 +- .../src/__data__/distributedRegionsData.ts | 193 +++++ .../src/__data__/productionRegionsData.ts | 788 ++++++++++++++++++ .../manager/src/assets/icons/ResizeWindow.svg | 4 + .../src/dev-tools/EnvironmentToggleTool.tsx | 16 +- .../manager/src/dev-tools/FeatureFlagTool.tsx | 81 +- .../manager/src/dev-tools/MockDataTool.tsx | 17 - .../src/dev-tools/ServiceWorkerTool.tsx | 392 ++++++++- .../components/BaselinePresetOptions.tsx | 23 + .../dev-tools/components/DevToolSelect.tsx | 23 + .../src/dev-tools/components/Draggable.tsx | 133 +++ .../components/ExtraPresetOptionCheckbox.tsx | 102 +++ .../components/ExtraPresetOptionSelect.tsx | 50 ++ .../components/ExtraPresetOptions.tsx | 72 ++ .../src/dev-tools/components/SeedOptions.tsx | 78 ++ packages/manager/src/dev-tools/constants.ts | 11 + packages/manager/src/dev-tools/dev-tools.css | 536 +++++++++++- packages/manager/src/dev-tools/dev-tools.tsx | 204 ++++- packages/manager/src/dev-tools/load.ts | 126 ++- packages/manager/src/dev-tools/utils.ts | 148 ++++ packages/manager/src/factories/linodes.ts | 14 + packages/manager/src/index.tsx | 19 +- packages/manager/src/mocks/indexedDB.ts | 527 ++++++++++++ packages/manager/src/mocks/mockPreset.ts | 38 + packages/manager/src/mocks/mockState.ts | 49 ++ packages/manager/src/mocks/mswWorkers.ts | 14 + .../mocks/presets/baseline/accountReview.ts | 23 + .../presets/baseline/apiMaintenanceMode.ts | 26 + .../src/mocks/presets/baseline/apiOffline.ts | 21 + .../src/mocks/presets/baseline/apiUnstable.ts | 35 + .../src/mocks/presets/baseline/crud.ts | 25 + .../src/mocks/presets/baseline/legacy.ts | 17 + .../src/mocks/presets/baseline/noMocks.ts | 16 + .../src/mocks/presets/crud/handlers/events.ts | 131 +++ .../mocks/presets/crud/handlers/firewalls.ts | 28 + .../mocks/presets/crud/handlers/linodes.ts | 453 ++++++++++ .../presets/crud/handlers/placementGroups.ts | 307 +++++++ .../mocks/presets/crud/handlers/volumes.ts | 229 +++++ .../manager/src/mocks/presets/crud/linodes.ts | 34 + .../src/mocks/presets/crud/placementGroups.ts | 22 + .../src/mocks/presets/crud/seeds/index.ts | 5 + .../src/mocks/presets/crud/seeds/linodes.ts | 38 + .../presets/crud/seeds/placementGroups.ts | 35 + .../src/mocks/presets/crud/seeds/utils.ts | 91 ++ .../src/mocks/presets/crud/seeds/volumes.ts | 28 + .../manager/src/mocks/presets/crud/volumes.ts | 15 + .../presets/extra/account/childAccount.ts | 49 ++ .../presets/extra/account/managedDisabled.ts | 26 + .../presets/extra/account/managedEnabled.ts | 26 + .../presets/extra/account/parentAccount.ts | 52 ++ .../src/mocks/presets/extra/api/api.ts | 35 + .../extra/regions/coreAndDistributed.ts | 26 + .../mocks/presets/extra/regions/coreOnly.ts | 25 + .../presets/extra/regions/legacyRegions.ts | 25 + packages/manager/src/mocks/presets/index.ts | 53 ++ packages/manager/src/mocks/serverHandlers.ts | 8 + packages/manager/src/mocks/testBrowser.ts | 5 - packages/manager/src/mocks/types.ts | 102 +++ .../manager/src/mocks/utilities/events.ts | 101 +++ .../manager/src/mocks/utilities/pagination.ts | 17 + .../manager/src/mocks/utilities/response.ts | 162 ++++ .../manager/src/utilities/rootManager.test.ts | 38 + packages/manager/src/utilities/rootManager.ts | 21 + packages/manager/src/utilities/storage.ts | 4 +- 68 files changed, 5890 insertions(+), 198 deletions(-) create mode 100644 packages/manager/.changeset/pr-10610-tech-stories-1723046371666.md create mode 100644 packages/manager/src/__data__/distributedRegionsData.ts create mode 100644 packages/manager/src/__data__/productionRegionsData.ts create mode 100644 packages/manager/src/assets/icons/ResizeWindow.svg delete mode 100644 packages/manager/src/dev-tools/MockDataTool.tsx create mode 100644 packages/manager/src/dev-tools/components/BaselinePresetOptions.tsx create mode 100644 packages/manager/src/dev-tools/components/DevToolSelect.tsx create mode 100644 packages/manager/src/dev-tools/components/Draggable.tsx create mode 100644 packages/manager/src/dev-tools/components/ExtraPresetOptionCheckbox.tsx create mode 100644 packages/manager/src/dev-tools/components/ExtraPresetOptionSelect.tsx create mode 100644 packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx create mode 100644 packages/manager/src/dev-tools/components/SeedOptions.tsx create mode 100644 packages/manager/src/dev-tools/constants.ts create mode 100644 packages/manager/src/dev-tools/utils.ts create mode 100644 packages/manager/src/mocks/indexedDB.ts create mode 100644 packages/manager/src/mocks/mockPreset.ts create mode 100644 packages/manager/src/mocks/mockState.ts create mode 100644 packages/manager/src/mocks/mswWorkers.ts create mode 100644 packages/manager/src/mocks/presets/baseline/accountReview.ts create mode 100644 packages/manager/src/mocks/presets/baseline/apiMaintenanceMode.ts create mode 100644 packages/manager/src/mocks/presets/baseline/apiOffline.ts create mode 100644 packages/manager/src/mocks/presets/baseline/apiUnstable.ts create mode 100644 packages/manager/src/mocks/presets/baseline/crud.ts create mode 100644 packages/manager/src/mocks/presets/baseline/legacy.ts create mode 100644 packages/manager/src/mocks/presets/baseline/noMocks.ts create mode 100644 packages/manager/src/mocks/presets/crud/handlers/events.ts create mode 100644 packages/manager/src/mocks/presets/crud/handlers/firewalls.ts create mode 100644 packages/manager/src/mocks/presets/crud/handlers/linodes.ts create mode 100644 packages/manager/src/mocks/presets/crud/handlers/placementGroups.ts create mode 100644 packages/manager/src/mocks/presets/crud/handlers/volumes.ts create mode 100644 packages/manager/src/mocks/presets/crud/linodes.ts create mode 100644 packages/manager/src/mocks/presets/crud/placementGroups.ts create mode 100644 packages/manager/src/mocks/presets/crud/seeds/index.ts create mode 100644 packages/manager/src/mocks/presets/crud/seeds/linodes.ts create mode 100644 packages/manager/src/mocks/presets/crud/seeds/placementGroups.ts create mode 100644 packages/manager/src/mocks/presets/crud/seeds/utils.ts create mode 100644 packages/manager/src/mocks/presets/crud/seeds/volumes.ts create mode 100644 packages/manager/src/mocks/presets/crud/volumes.ts create mode 100644 packages/manager/src/mocks/presets/extra/account/childAccount.ts create mode 100644 packages/manager/src/mocks/presets/extra/account/managedDisabled.ts create mode 100644 packages/manager/src/mocks/presets/extra/account/managedEnabled.ts create mode 100644 packages/manager/src/mocks/presets/extra/account/parentAccount.ts create mode 100644 packages/manager/src/mocks/presets/extra/api/api.ts create mode 100644 packages/manager/src/mocks/presets/extra/regions/coreAndDistributed.ts create mode 100644 packages/manager/src/mocks/presets/extra/regions/coreOnly.ts create mode 100644 packages/manager/src/mocks/presets/extra/regions/legacyRegions.ts create mode 100644 packages/manager/src/mocks/presets/index.ts delete mode 100644 packages/manager/src/mocks/testBrowser.ts create mode 100644 packages/manager/src/mocks/types.ts create mode 100644 packages/manager/src/mocks/utilities/events.ts create mode 100644 packages/manager/src/mocks/utilities/pagination.ts create mode 100644 packages/manager/src/mocks/utilities/response.ts create mode 100644 packages/manager/src/utilities/rootManager.test.ts create mode 100644 packages/manager/src/utilities/rootManager.ts diff --git a/docs/development-guide/09-mocking-data.md b/docs/development-guide/09-mocking-data.md index b12b902ae99..2d6ad76504b 100644 --- a/docs/development-guide/09-mocking-data.md +++ b/docs/development-guide/09-mocking-data.md @@ -6,9 +6,9 @@ This guide covers various methods of mocking data while developing or testing Cl Often when developing a feature you'll need your account or resources to be in a specific state. In other words, you'll need to be receiving specific data from the API. -The best way to do this is to _mock the API_. This is made simple using **factories** and the **mock service worker**. +The best way to do this is to _mock the API_. This is made simple using [**factories**](https://github.com/linode/manager/tree/develop/packages/manager/src/factories) and the [**mock service worker**](https://github.com/linode/manager/tree/develop/packages/manager/src/mocks) tooling suite. -**Factories** +### Factories We use [factory.ts](https://www.npmjs.com/package/factory.ts) to generate mock data. With factory.ts you define a base "factory" for a given type, then use the factory to generate real TypeScript objects: ```ts @@ -41,31 +41,43 @@ const linodeList = linodeFactory.buildList(10, { region: "eu-west" }); // [{ id: 3, label: 'linode-3', region: 'eu-west' }, ...9 more ] ``` -**Mock Service Worker** -The [Mock Service Worker](https://mswjs.io/) package intercepts requests at the network level and returns the response you define. +### Intercepting Requests -To enable the MSW, open the Local Dev Tools and check the "Mock Service Worker" checkbox. Request handlers are defined in `packages/manager/src/mocks/serverHandlers.ts`. Here's an example request handler: +The [Mock Service Worker](https://mswjs.io/) package intercepts requests at the network level and returns the response you defined in the relevant factory. +Generic example: ```ts -// packages/manager/src/mocks/serverHandlers.ts - -import { rest } from "msw"; +import { http } from "msw"; const handlers = [ - http.get("*/profile", () => { - // - const profile = profileFactory.build({ restricted: true }); - return HttpResponse.json((profile)); + http.get("*/entity/:id", ({ params }) => { + const id = Number(params.id); + const entity = entityFactory.build({ id }); + + return HttpResponse.json(entity); }), - // ... other handlers ]; ``` -When the MSW is enabled, any GET request that matches `*/profile` will be intercepted, and the response will be the `profile` JSON object we gave to the `res()` function. +In this example, when MSW is enabled, any GET request that matches `*/entity/:id` will be intercepted, and the response will be the `entity` JSON object we built from a factory. + +The application treats these as _real_ network requests and will behave as though this data is coming from the actual API. + +## Dev Tools & MSW -The great thing about this is that the application treats these as _real_ network requests and will behave as though this data is coming from the actual API. +To enable the MSW, open the Local Dev Tools (development only, by clicking the 🛠️ icon in the lower left of your screen). You will be presented with a variety of options to facilitate local development. Click the "Enable MSW" checkbox to get started, then choose a Base Preset: +- "**Preset Mocking**": Will give you the ability to use predefined mock types, available in the "Presets" column underneath. Those presets return static data for commonly used non-dynamic data, and a parameter to customize the API's response time. +- "**CRUD**": The primary mocking mode for Cloud Manager. This mode is an API-like mocking behavior, storing and persisting mock data in the `indexDB` browser storage (`Developer Tools > Application > IndexedDB > MockDB`). Two features are available while using this mode: + - **`mockState`**: the data a user inputs via Cloud Manager UI. When a handler is defined (`src/mocks/handlers/handler.ts`), it will be stored in the corresponding content type and persist in local storage, unless reset in dev tools or cleared manually. + - **`seedState`**: the data stored by content seeders (`src/mocks/seeds/seed.ts`) for which the count can be customized in the UI (left panel). This way of populating data is particularly helpful for testing large accounts, pagination and filtering.

+ The data for both these modes can be interacted with and updated in the UI. However, it is worth mentioning that any seed data deleted in the UI will surface back up in the UI on refresh if still enabled in the Dev Tools. +- "**Legacy MSW handlers**". This preset preserves the MSW legacy data/mode, which is essentially a static set of handlers that covers most (if not all) APIv4 intercepts. **IMPORTANT**: this mode is considered `@deprecated`. It remains available for convenience and backward compatibility, it is however discouraged to add new handlers to it. Those should be added to the CRUD baseline preset instead. +- "**Account Activation Required**", "**API Maintenance Mode**", "**API Offline**" and "**API Unstable**" emulate hard to reproduce cases a user may encounter. -Another advantage is that server handler code can easily be shared with code reviewers (either by checking it into source control or providing the diff). +
+ +> [!IMPORTANT] +> In CRUD mode, only data types that have RESTFUL handlers will be mocked. Since this is part of the MSW V2 tooling, only **some** content types are currently supported and return CRUD mock data. Endpoints that are not intercepted will return alpha/beta/production data depending on your chosen environment.

Please follow existing examples to add handlers and seeders and ensure to use `mswDB` utils to keep your stateful data synced with the `indexedDB` storage. ## Mocking feature flags @@ -74,26 +86,16 @@ Cloud Manager uses [LaunchDarkly](https://github.com/launchdarkly/react-client-s To run Cloud Manager without feature flags enabled, either: - Run the app without `REACT_APP_LAUNCH_DARKLY_ID` defined in `.env`, or: -- [Block network requests](https://developers.google.com/web/updates/2017/04/devtools-release-notes#block-requests) to `*launchdarkly*`. +- In the browser Developer Tools, block network requests to `*launchdarkly*`. To run Cloud Manager with _specific values_ assigned to feature flags: -1. Run Cloud Manager without feature flags using a method listed above. -2. Open `src/containers/withFeatureFlagProvider.container.ts`. -3. Supply an `options.bootstrap` map to `withLDProvider`. - -**Example:** +1. Use the Cloud Manager Dev Tools Feature Flags panel +2. Run Cloud Manager without feature flags using a method listed above. -```js -options: { - bootstrap: { - isFeatureEnabled: true; // <-- Mocked flags here. - } -} ``` - ## Changing user preferences Since [user preferences are tied to OAuth clients](https://developers.linode.com/api/v4/profile-preferences), to change Cloud Manager preferences you must grab the short-lived token from the app and use that to curl. -As a convenience, there is a preference editor in Cloud accessible by going to `/profile/settings?preferenceEditor=true`. It allows you to enter arbitrary JSON and submit it (validating that it's valid JSON first). This makes it easier to quickly edit preferences when developing features that depend on it. +As a convenience, there is a preference editor in Cloud accessible by going to `/profile/settings?preferenceEditor=true`. It allows you to enter arbitrary JSON and submit it (validating that it's valid JSON first). This makes it easier to quickly edit preferences when developing features that depend on it. A quick link to those preferences is also available in the Cloud Manager Dev Tools. diff --git a/docs/development-guide/10-local-dev-tools.md b/docs/development-guide/10-local-dev-tools.md index 172269fb849..35bc1c47ce1 100644 --- a/docs/development-guide/10-local-dev-tools.md +++ b/docs/development-guide/10-local-dev-tools.md @@ -16,9 +16,9 @@ The flags on/off values are stored in local storage for convenience and will be By default, the boolean flags checkboxes represent their true values as returned by Launch Darkly (dev environment). Hitting the reset button will bring them back to those default values and clear local storage. -## Theme Select +## MSW Tools -The theme select in dev tools is a convenient way to store the theme choice while MSW is enabled (it won't affect your actual Application settings/preferences). The default is "system". +Please refer to the [Mocking Data](https://github.com/linode/manager/blob/develop/docs/development-guide/09-mocking-data.md) section of this documentation to get familiar with the usage of these tools. ## Writing a new tool diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index c17934076bb..e95c7dc3f13 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -1,6 +1,7 @@ import { COUNTRY_CODE_TO_CONTINENT_CODE } from './constants'; export type Capabilities = + | 'Backups' | 'Bare Metal' | 'Block Storage' | 'Block Storage Encryption' diff --git a/packages/manager/.changeset/pr-10610-tech-stories-1723046371666.md b/packages/manager/.changeset/pr-10610-tech-stories-1723046371666.md new file mode 100644 index 00000000000..7429ffc95fa --- /dev/null +++ b/packages/manager/.changeset/pr-10610-tech-stories-1723046371666.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Introduce Mock Service Worker V2 ([#10610](https://github.com/linode/manager/pull/10610)) diff --git a/packages/manager/.storybook/preview.tsx b/packages/manager/.storybook/preview.tsx index 5ea09efff9d..55197641f90 100644 --- a/packages/manager/.storybook/preview.tsx +++ b/packages/manager/.storybook/preview.tsx @@ -13,7 +13,7 @@ import { wrapWithTheme } from '../src/utilities/testHelpers'; import { useDarkMode } from 'storybook-dark-mode'; import { DocsContainer as BaseContainer } from '@storybook/addon-docs'; import { themes } from '@storybook/theming'; -import { worker } from '../src/mocks/testBrowser'; +import { storybookWorker } from '../src/mocks/mswWorkers'; import '../src/index.css'; // TODO: M3-6705 Remove this when replacing @reach/tabs with MUI Tabs @@ -49,7 +49,7 @@ const preview: Preview = { ], loaders: [ async () => ({ - msw: await worker?.start(), + msw: await storybookWorker?.start(), }), ], parameters: { diff --git a/packages/manager/src/__data__/distributedRegionsData.ts b/packages/manager/src/__data__/distributedRegionsData.ts new file mode 100644 index 00000000000..051230aae49 --- /dev/null +++ b/packages/manager/src/__data__/distributedRegionsData.ts @@ -0,0 +1,193 @@ +import type { Region } from '@linode/api-v4'; + +export const distributedRegions: Region[] = [ + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'us', + id: 'us-den-edge-1', + label: 'Edge - Denver, CO', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'de', + id: 'de-ham-edge-1', + label: 'Edge - Hamburg, DE', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'fr', + id: 'fr-mrs-edge-1', + label: 'Edge - Marseille, FR', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'za', + id: 'za-jnb-edge-1', + label: 'Edge - Johannesburg, ZA\t', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'my', + id: 'my-kul-edge-1', + label: 'Edge - Kuala Lumpur, MY', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'co', + id: 'co-bog-edge-1', + label: 'Edge - Bogotá, CO', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'mx', + id: 'mx-qro-edge-1', + label: 'Edge - Querétaro, MX', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'us', + id: 'us-hou-edge-1', + label: 'Edge - Houston, TX', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Cloud Firewall', + 'Distributed Plans', + 'Placement Group', + ], + country: 'cl', + id: 'cl-scl-edge-1', + label: 'Edge - Santiago, CL', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: '173.223.100.53, 173.223.101.53', + ipv6: '2600:14C0:A:0:0:0:0:53, 2600:14C0:B:0:0:0:0:53', + }, + site_type: 'distributed', + status: 'ok', + }, +]; diff --git a/packages/manager/src/__data__/productionRegionsData.ts b/packages/manager/src/__data__/productionRegionsData.ts new file mode 100644 index 00000000000..ecef9b995b1 --- /dev/null +++ b/packages/manager/src/__data__/productionRegionsData.ts @@ -0,0 +1,788 @@ +import type { Region } from '@linode/api-v4'; + +/** + * Array of Regions that simulate APIv4 Production region data. + */ +export const productionRegions: Region[] = [ + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'in', + id: 'ap-west', + label: 'Mumbai, IN', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.105.34.5, 172.105.35.5, 172.105.36.5, 172.105.37.5, 172.105.38.5, 172.105.39.5, 172.105.40.5, 172.105.41.5, 172.105.42.5, 172.105.43.5', + ipv6: + '2400:8904::f03c:91ff:fea5:659, 2400:8904::f03c:91ff:fea5:9282, 2400:8904::f03c:91ff:fea5:b9b3, 2400:8904::f03c:91ff:fea5:925a, 2400:8904::f03c:91ff:fea5:22cb, 2400:8904::f03c:91ff:fea5:227a, 2400:8904::f03c:91ff:fea5:924c, 2400:8904::f03c:91ff:fea5:f7e2, 2400:8904::f03c:91ff:fea5:2205, 2400:8904::f03c:91ff:fea5:9207', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'ca', + id: 'ca-central', + label: 'Toronto, CA', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.105.0.5, 172.105.3.5, 172.105.4.5, 172.105.5.5, 172.105.6.5, 172.105.7.5, 172.105.8.5, 172.105.9.5, 172.105.10.5, 172.105.11.5', + ipv6: + '2600:3c04::f03c:91ff:fea9:f63, 2600:3c04::f03c:91ff:fea9:f6d, 2600:3c04::f03c:91ff:fea9:f80, 2600:3c04::f03c:91ff:fea9:f0f, 2600:3c04::f03c:91ff:fea9:f99, 2600:3c04::f03c:91ff:fea9:fbd, 2600:3c04::f03c:91ff:fea9:fdd, 2600:3c04::f03c:91ff:fea9:fe2, 2600:3c04::f03c:91ff:fea9:f68, 2600:3c04::f03c:91ff:fea9:f4a', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'au', + id: 'ap-southeast', + label: 'Sydney, AU', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.105.166.5, 172.105.169.5, 172.105.168.5, 172.105.172.5, 172.105.162.5, 172.105.170.5, 172.105.167.5, 172.105.171.5, 172.105.181.5, 172.105.161.5', + ipv6: + '2400:8907::f03c:92ff:fe6e:ec8, 2400:8907::f03c:92ff:fe6e:98e4, 2400:8907::f03c:92ff:fe6e:1c58, 2400:8907::f03c:92ff:fe6e:c299, 2400:8907::f03c:92ff:fe6e:c210, 2400:8907::f03c:92ff:fe6e:c219, 2400:8907::f03c:92ff:fe6e:1c5c, 2400:8907::f03c:92ff:fe6e:c24e, 2400:8907::f03c:92ff:fe6e:e6b, 2400:8907::f03c:92ff:fe6e:e3d', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Managed Databases', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'us', + id: 'us-iad', + label: 'Washington, DC', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '139.144.192.62, 139.144.192.60, 139.144.192.61, 139.144.192.53, 139.144.192.54, 139.144.192.67, 139.144.192.69, 139.144.192.66, 139.144.192.52, 139.144.192.68', + ipv6: + '2600:3c05::f03c:93ff:feb6:43b6, 2600:3c05::f03c:93ff:feb6:4365, 2600:3c05::f03c:93ff:feb6:43c2, 2600:3c05::f03c:93ff:feb6:e441, 2600:3c05::f03c:93ff:feb6:94ef, 2600:3c05::f03c:93ff:feb6:94ba, 2600:3c05::f03c:93ff:feb6:94a8, 2600:3c05::f03c:93ff:feb6:9413, 2600:3c05::f03c:93ff:feb6:9443, 2600:3c05::f03c:93ff:feb6:94e0', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Managed Databases', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'us', + id: 'us-ord', + label: 'Chicago, IL', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.232.0.17, 172.232.0.16, 172.232.0.21, 172.232.0.13, 172.232.0.22, 172.232.0.9, 172.232.0.19, 172.232.0.20, 172.232.0.15, 172.232.0.18', + ipv6: + '2600:3c06::f03c:93ff:fed0:e5fc, 2600:3c06::f03c:93ff:fed0:e54b, 2600:3c06::f03c:93ff:fed0:e572, 2600:3c06::f03c:93ff:fed0:e530, 2600:3c06::f03c:93ff:fed0:e597, 2600:3c06::f03c:93ff:fed0:e511, 2600:3c06::f03c:93ff:fed0:e5f2, 2600:3c06::f03c:93ff:fed0:e5bf, 2600:3c06::f03c:93ff:fed0:e529, 2600:3c06::f03c:93ff:fed0:e5a3', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Disk Encryption', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Managed Databases', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'fr', + id: 'fr-par', + label: 'Paris, FR', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.232.32.21, 172.232.32.23, 172.232.32.17, 172.232.32.18, 172.232.32.16, 172.232.32.22, 172.232.32.20, 172.232.32.14, 172.232.32.11, 172.232.32.12', + ipv6: + '2600:3c07::f03c:93ff:fef2:2e63, 2600:3c07::f03c:93ff:fef2:2ec7, 2600:3c07::f03c:93ff:fef2:0dee, 2600:3c07::f03c:93ff:fef2:0d25, 2600:3c07::f03c:93ff:fef2:0de0, 2600:3c07::f03c:93ff:fef2:2e29, 2600:3c07::f03c:93ff:fef2:0dda, 2600:3c07::f03c:93ff:fef2:0d82, 2600:3c07::f03c:93ff:fef2:b3ac, 2600:3c07::f03c:93ff:fef2:b3a8', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'us', + id: 'us-sea', + label: 'Seattle, WA', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.232.160.19, 172.232.160.21, 172.232.160.17, 172.232.160.15, 172.232.160.18, 172.232.160.8, 172.232.160.12, 172.232.160.11, 172.232.160.14, 172.232.160.16', + ipv6: + '2600:3c0a::f03c:93ff:fe54:c6da, 2600:3c0a::f03c:93ff:fe54:c691, 2600:3c0a::f03c:93ff:fe54:c68d, 2600:3c0a::f03c:93ff:fe54:c61e, 2600:3c0a::f03c:93ff:fe54:c653, 2600:3c0a::f03c:93ff:fe54:c64c, 2600:3c0a::f03c:93ff:fe54:c68a, 2600:3c0a::f03c:93ff:fe54:c697, 2600:3c0a::f03c:93ff:fe54:c60f, 2600:3c0a::f03c:93ff:fe54:c6a0', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'br', + id: 'br-gru', + label: 'Sao Paulo, BR', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.233.0.4, 172.233.0.9, 172.233.0.7, 172.233.0.12, 172.233.0.5, 172.233.0.13, 172.233.0.10, 172.233.0.6, 172.233.0.8, 172.233.0.11', + ipv6: + '2600:3c0d::f03c:93ff:fe3d:51cb, 2600:3c0d::f03c:93ff:fe3d:51a7, 2600:3c0d::f03c:93ff:fe3d:51a9, 2600:3c0d::f03c:93ff:fe3d:5119, 2600:3c0d::f03c:93ff:fe3d:51fe, 2600:3c0d::f03c:93ff:fe3d:517c, 2600:3c0d::f03c:93ff:fe3d:5144, 2600:3c0d::f03c:93ff:fe3d:5170, 2600:3c0d::f03c:93ff:fe3d:51cc, 2600:3c0d::f03c:93ff:fe3d:516c', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'nl', + id: 'nl-ams', + label: 'Amsterdam, NL', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.233.33.36, 172.233.33.38, 172.233.33.35, 172.233.33.39, 172.233.33.34, 172.233.33.33, 172.233.33.31, 172.233.33.30, 172.233.33.37, 172.233.33.32', + ipv6: + '2600:3c0e::f03c:93ff:fe9d:2d10, 2600:3c0e::f03c:93ff:fe9d:2d89, 2600:3c0e::f03c:93ff:fe9d:2d79, 2600:3c0e::f03c:93ff:fe9d:2d96, 2600:3c0e::f03c:93ff:fe9d:2da5, 2600:3c0e::f03c:93ff:fe9d:2d34, 2600:3c0e::f03c:93ff:fe9d:2d68, 2600:3c0e::f03c:93ff:fe9d:2d17, 2600:3c0e::f03c:93ff:fe9d:2d45, 2600:3c0e::f03c:93ff:fe9d:2d5c', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'se', + id: 'se-sto', + label: 'Stockholm, SE', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.232.128.24, 172.232.128.26, 172.232.128.20, 172.232.128.22, 172.232.128.25, 172.232.128.19, 172.232.128.23, 172.232.128.18, 172.232.128.21, 172.232.128.27', + ipv6: + '2600:3c09::f03c:93ff:fea9:4dbe, 2600:3c09::f03c:93ff:fea9:4d63, 2600:3c09::f03c:93ff:fea9:4dce, 2600:3c09::f03c:93ff:fea9:4dbb, 2600:3c09::f03c:93ff:fea9:4d99, 2600:3c09::f03c:93ff:fea9:4d26, 2600:3c09::f03c:93ff:fea9:4de0, 2600:3c09::f03c:93ff:fea9:4d69, 2600:3c09::f03c:93ff:fea9:4dbf, 2600:3c09::f03c:93ff:fea9:4da6', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'es', + id: 'es-mad', + label: 'Madrid, ES', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.233.111.6, 172.233.111.17, 172.233.111.21, 172.233.111.25, 172.233.111.19, 172.233.111.12, 172.233.111.26, 172.233.111.16, 172.233.111.18, 172.233.111.9', + ipv6: + '2a01:7e02::f03c:93ff:feea:b585, 2a01:7e02::f03c:93ff:feea:b5ab, 2a01:7e02::f03c:93ff:feea:b5c6, 2a01:7e02::f03c:93ff:feea:b592, 2a01:7e02::f03c:93ff:feea:b5aa, 2a01:7e02::f03c:93ff:feea:b5d3, 2a01:7e02::f03c:93ff:feea:b5d7, 2a01:7e02::f03c:93ff:feea:b528, 2a01:7e02::f03c:93ff:feea:b522, 2a01:7e02::f03c:93ff:feea:b51a', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'in', + id: 'in-maa', + label: 'Chennai, IN', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.232.96.17, 172.232.96.26, 172.232.96.19, 172.232.96.20, 172.232.96.25, 172.232.96.21, 172.232.96.18, 172.232.96.22, 172.232.96.23, 172.232.96.24', + ipv6: + '2600:3c08::f03c:93ff:fe7c:1135, 2600:3c08::f03c:93ff:fe7c:11f8, 2600:3c08::f03c:93ff:fe7c:11d2, 2600:3c08::f03c:93ff:fe7c:11a7, 2600:3c08::f03c:93ff:fe7c:11ad, 2600:3c08::f03c:93ff:fe7c:110a, 2600:3c08::f03c:93ff:fe7c:11f9, 2600:3c08::f03c:93ff:fe7c:1137, 2600:3c08::f03c:93ff:fe7c:11db, 2600:3c08::f03c:93ff:fe7c:1164', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'jp', + id: 'jp-osa', + label: 'Osaka, JP', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.233.64.44, 172.233.64.43, 172.233.64.37, 172.233.64.40, 172.233.64.46, 172.233.64.41, 172.233.64.39, 172.233.64.42, 172.233.64.45, 172.233.64.38', + ipv6: + '2400:8905::f03c:93ff:fe9d:b085, 2400:8905::f03c:93ff:fe9d:b012, 2400:8905::f03c:93ff:fe9d:b09b, 2400:8905::f03c:93ff:fe9d:b0d8, 2400:8905::f03c:93ff:fe9d:259f, 2400:8905::f03c:93ff:fe9d:b006, 2400:8905::f03c:93ff:fe9d:b084, 2400:8905::f03c:93ff:fe9d:b0ce, 2400:8905::f03c:93ff:fe9d:25ea, 2400:8905::f03c:93ff:fe9d:b086', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'it', + id: 'it-mil', + label: 'Milan, IT', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.232.192.19, 172.232.192.18, 172.232.192.16, 172.232.192.20, 172.232.192.24, 172.232.192.21, 172.232.192.22, 172.232.192.17, 172.232.192.15, 172.232.192.23', + ipv6: + '2600:3c0b::f03c:93ff:feba:d513, 2600:3c0b::f03c:93ff:feba:d5c3, 2600:3c0b::f03c:93ff:feba:d597, 2600:3c0b::f03c:93ff:feba:d5fb, 2600:3c0b::f03c:93ff:feba:d51f, 2600:3c0b::f03c:93ff:feba:d58e, 2600:3c0b::f03c:93ff:feba:d5d5, 2600:3c0b::f03c:93ff:feba:d534, 2600:3c0b::f03c:93ff:feba:d57c, 2600:3c0b::f03c:93ff:feba:d529', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'us', + id: 'us-mia', + label: 'Miami, FL', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.233.160.34, 172.233.160.27, 172.233.160.30, 172.233.160.29, 172.233.160.32, 172.233.160.28, 172.233.160.33, 172.233.160.26, 172.233.160.25, 172.233.160.31', + ipv6: + '2a01:7e04::f03c:93ff:fead:d31f, 2a01:7e04::f03c:93ff:fead:d37f, 2a01:7e04::f03c:93ff:fead:d30c, 2a01:7e04::f03c:93ff:fead:d318, 2a01:7e04::f03c:93ff:fead:d316, 2a01:7e04::f03c:93ff:fead:d339, 2a01:7e04::f03c:93ff:fead:d367, 2a01:7e04::f03c:93ff:fead:d395, 2a01:7e04::f03c:93ff:fead:d3d0, 2a01:7e04::f03c:93ff:fead:d38e', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'id', + id: 'id-cgk', + label: 'Jakarta, ID', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.232.224.23, 172.232.224.32, 172.232.224.26, 172.232.224.27, 172.232.224.21, 172.232.224.24, 172.232.224.22, 172.232.224.20, 172.232.224.31, 172.232.224.28', + ipv6: + '2600:3c0c::f03c:93ff:feed:a90b, 2600:3c0c::f03c:93ff:feed:a9a5, 2600:3c0c::f03c:93ff:feed:a935, 2600:3c0c::f03c:93ff:feed:a930, 2600:3c0c::f03c:93ff:feed:a95c, 2600:3c0c::f03c:93ff:feed:a9ad, 2600:3c0c::f03c:93ff:feed:a9f2, 2600:3c0c::f03c:93ff:feed:a9ff, 2600:3c0c::f03c:93ff:feed:a9c8, 2600:3c0c::f03c:93ff:feed:a96b', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'VPCs', + 'Metadata', + 'Premium Plans', + 'Placement Group', + ], + country: 'us', + id: 'us-lax', + label: 'Los Angeles, CA', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '172.233.128.45, 172.233.128.38, 172.233.128.53, 172.233.128.37, 172.233.128.34, 172.233.128.36, 172.233.128.33, 172.233.128.39, 172.233.128.43, 172.233.128.44', + ipv6: + '2a01:7e03::f03c:93ff:feb1:b789, 2a01:7e03::f03c:93ff:feb1:b717, 2a01:7e03::f03c:93ff:feb1:b707, 2a01:7e03::f03c:93ff:feb1:b7ab, 2a01:7e03::f03c:93ff:feb1:b7e2, 2a01:7e03::f03c:93ff:feb1:b709, 2a01:7e03::f03c:93ff:feb1:b7a6, 2a01:7e03::f03c:93ff:feb1:b750, 2a01:7e03::f03c:93ff:feb1:b76e, 2a01:7e03::f03c:93ff:feb1:b7a2', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'us', + id: 'us-central', + label: 'Dallas, TX', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '72.14.179.5, 72.14.188.5, 173.255.199.5, 66.228.53.5, 96.126.122.5, 96.126.124.5, 96.126.127.5, 198.58.107.5, 198.58.111.5, 23.239.24.5', + ipv6: + '2600:3c00::2, 2600:3c00::9, 2600:3c00::7, 2600:3c00::5, 2600:3c00::3, 2600:3c00::8, 2600:3c00::6, 2600:3c00::4, 2600:3c00::c, 2600:3c00::b', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'us', + id: 'us-west', + label: 'Fremont, CA', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '173.230.145.5, 173.230.147.5, 173.230.155.5, 173.255.212.5, 173.255.219.5, 173.255.241.5, 173.255.243.5, 173.255.244.5, 74.207.241.5, 74.207.242.5', + ipv6: + '2600:3c01::2, 2600:3c01::9, 2600:3c01::5, 2600:3c01::7, 2600:3c01::3, 2600:3c01::8, 2600:3c01::4, 2600:3c01::b, 2600:3c01::c, 2600:3c01::6', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'us', + id: 'us-southeast', + label: 'Atlanta, GA', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '74.207.231.5, 173.230.128.5, 173.230.129.5, 173.230.136.5, 173.230.140.5, 66.228.59.5, 66.228.62.5, 50.116.35.5, 50.116.41.5, 23.239.18.5', + ipv6: + '2600:3c02::3, 2600:3c02::5, 2600:3c02::4, 2600:3c02::6, 2600:3c02::c, 2600:3c02::7, 2600:3c02::2, 2600:3c02::9, 2600:3c02::8, 2600:3c02::b', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'us', + id: 'us-east', + label: 'Newark, NJ', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '66.228.42.5, 96.126.106.5, 50.116.53.5, 50.116.58.5, 50.116.61.5, 50.116.62.5, 66.175.211.5, 97.107.133.4, 207.192.69.4, 207.192.69.5', + ipv6: + '2600:3c03::7, 2600:3c03::4, 2600:3c03::9, 2600:3c03::6, 2600:3c03::3, 2600:3c03::c, 2600:3c03::5, 2600:3c03::b, 2600:3c03::2, 2600:3c03::8', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'gb', + id: 'eu-west', + label: 'London, UK', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '178.79.182.5, 176.58.107.5, 176.58.116.5, 176.58.121.5, 151.236.220.5, 212.71.252.5, 212.71.253.5, 109.74.192.20, 109.74.193.20, 109.74.194.20', + ipv6: + '2a01:7e00::9, 2a01:7e00::3, 2a01:7e00::c, 2a01:7e00::5, 2a01:7e00::6, 2a01:7e00::8, 2a01:7e00::b, 2a01:7e00::4, 2a01:7e00::7, 2a01:7e00::2', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'sg', + id: 'ap-south', + label: 'Singapore, SG', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '139.162.11.5, 139.162.13.5, 139.162.14.5, 139.162.15.5, 139.162.16.5, 139.162.21.5, 139.162.27.5, 103.3.60.18, 103.3.60.19, 103.3.60.20', + ipv6: + '2400:8901::5, 2400:8901::4, 2400:8901::b, 2400:8901::3, 2400:8901::9, 2400:8901::2, 2400:8901::8, 2400:8901::7, 2400:8901::c, 2400:8901::6', + }, + site_type: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Object Storage', + 'GPU Linodes', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'de', + id: 'eu-central', + label: 'Frankfurt, DE', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '139.162.130.5, 139.162.131.5, 139.162.132.5, 139.162.133.5, 139.162.134.5, 139.162.135.5, 139.162.136.5, 139.162.137.5, 139.162.138.5, 139.162.139.5', + 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: 'core', + status: 'ok', + }, + { + capabilities: [ + 'Linodes', + 'Backups', + 'NodeBalancers', + 'Block Storage', + 'Kubernetes', + 'Cloud Firewall', + 'Vlans', + 'Block Storage Migrations', + 'Managed Databases', + 'Metadata', + 'Placement Group', + ], + country: 'jp', + id: 'ap-northeast', + label: 'Tokyo, JP', + placement_group_limits: { + maximum_linodes_per_pg: 5, + maximum_pgs_per_customer: null, + }, + resolvers: { + ipv4: + '139.162.66.5, 139.162.67.5, 139.162.68.5, 139.162.69.5, 139.162.70.5, 139.162.71.5, 139.162.72.5, 139.162.73.5, 139.162.74.5, 139.162.75.5', + ipv6: + '2400:8902::3, 2400:8902::6, 2400:8902::c, 2400:8902::4, 2400:8902::2, 2400:8902::8, 2400:8902::7, 2400:8902::5, 2400:8902::b, 2400:8902::9', + }, + site_type: 'core', + status: 'ok', + }, +]; diff --git a/packages/manager/src/assets/icons/ResizeWindow.svg b/packages/manager/src/assets/icons/ResizeWindow.svg new file mode 100644 index 00000000000..c9ea7c2201a --- /dev/null +++ b/packages/manager/src/assets/icons/ResizeWindow.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/manager/src/dev-tools/EnvironmentToggleTool.tsx b/packages/manager/src/dev-tools/EnvironmentToggleTool.tsx index f0e83faa717..9d1ee2d48c6 100644 --- a/packages/manager/src/dev-tools/EnvironmentToggleTool.tsx +++ b/packages/manager/src/dev-tools/EnvironmentToggleTool.tsx @@ -3,6 +3,8 @@ import * as React from 'react'; import { storage } from 'src/utilities/storage'; +import { DevToolSelect } from './components/DevToolSelect'; + interface EnvironmentOption { apiRoot: string; clientID: string; @@ -53,15 +55,13 @@ export const EnvironmentToggleTool = () => { const localStorageEnv = storage.devToolsEnv.get(); const currentEnvLabel = localStorageEnv?.label; + const selectedOptionLabel = options[selectedOption]?.label; return ( - -

Environment

-
- - + diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 67de298235c..b2ebba17f10 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -1,4 +1,3 @@ -import Grid from '@mui/material/Unstable_Grid2'; import { useFlags as ldUseFlags } from 'launchdarkly-react-client-sdk'; import * as React from 'react'; import { useDispatch } from 'react-redux'; @@ -10,6 +9,7 @@ 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'; /** @@ -36,6 +36,33 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'databaseResize', label: 'Database Resize' }, ]; +const renderFlagItems = ( + flags: Partial, + onCheck: (e: React.ChangeEvent, flag: string) => void +) => { + return options.map((option) => { + const flagValue = flags[option.flag]; + const isChecked = + typeof flagValue === 'object' && 'enabled' in flagValue + ? Boolean(flagValue.enabled) + : Boolean(flagValue); + + return ( +
  • + onCheck(e, option.flag)} + type="checkbox" + /> + {option.label} +
  • + ); + }); +}; + export const FeatureFlagTool = withFeatureFlagProvider(() => { const dispatch: Dispatch = useDispatch(); const flags = useFlags(); @@ -74,42 +101,22 @@ export const FeatureFlagTool = withFeatureFlagProvider(() => { }; return ( - - -

    Feature Flags

    -
    - -
    - {options.map((thisOption) => { - const flagValue = flags[thisOption.flag]; - const isChecked = - typeof flagValue === 'object' && 'enabled' in flagValue - ? Boolean(flagValue.enabled) - : Boolean(flagValue); - return ( -
    - {thisOption.label} - handleCheck(e, thisOption.flag)} - type="checkbox" - /> -
    - ); - })} - +
    +
    + + Feature Flags + +
    +
    +
    +
      {renderFlagItems(flags, handleCheck)}
    +
    +
    +
    +
    +
    - - +
    +
    ); }); diff --git a/packages/manager/src/dev-tools/MockDataTool.tsx b/packages/manager/src/dev-tools/MockDataTool.tsx deleted file mode 100644 index eab11ae37d8..00000000000 --- a/packages/manager/src/dev-tools/MockDataTool.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import Grid from '@mui/material/Unstable_Grid2'; -import * as React from 'react'; - -import { ServiceWorkerTool } from './ServiceWorkerTool'; - -export const MockDataTool = () => { - return ( - - -

    Mock Data

    -
    - - - -
    - ); -}; diff --git a/packages/manager/src/dev-tools/ServiceWorkerTool.tsx b/packages/manager/src/dev-tools/ServiceWorkerTool.tsx index 6d7c6d70986..2094e0f7a54 100644 --- a/packages/manager/src/dev-tools/ServiceWorkerTool.tsx +++ b/packages/manager/src/dev-tools/ServiceWorkerTool.tsx @@ -1,30 +1,380 @@ import * as React from 'react'; -const LOCAL_STORAGE_KEY = 'msw'; +import { Tooltip } from 'src/components/Tooltip'; +import { mswDB } from 'src/mocks/indexedDB'; +import { extraMockPresets } from 'src/mocks/presets'; +import { dbSeeders } from 'src/mocks/presets/crud/seeds'; +import { removeSeeds } from 'src/mocks/presets/crud/seeds/utils'; -export const isMSWEnabled = - localStorage.getItem(LOCAL_STORAGE_KEY) === 'enabled'; +import { BaselinePresetOptions } from './components/BaselinePresetOptions'; +import { DevToolSelect } from './components/DevToolSelect'; +import { ExtraPresetOptions } from './components/ExtraPresetOptions'; +import { SeedOptions } from './components/SeedOptions'; +import { + getBaselinePreset, + getExtraPresets, + getExtraPresetsMap, + getSeeders, + getSeedsCountMap, + isMSWEnabled, + saveBaselinePreset, + saveExtraPresets, + saveExtraPresetsMap, + saveMSWEnabled, + saveSeeders, + saveSeedsCountMap, +} from './utils'; -export const setMSWEnabled = (enabled: boolean) => { - localStorage.setItem(LOCAL_STORAGE_KEY, enabled ? 'enabled' : 'disabled'); - window.location.reload(); -}; +import type { + MockPresetBaselineId, + MockPresetCrudId, + MockPresetExtraGroup, + MockPresetExtraId, + MockState, +} from 'src/mocks/types'; + +interface ServiceWorkerSaveState { + hasSaved: boolean; + hasUnsavedChanges: boolean; + mocksCleared?: boolean; +} +/** + * Renders the service worker tool. + */ export const ServiceWorkerTool = () => { + const loadedBaselinePreset = getBaselinePreset(); + const loadedExtraPresets = getExtraPresets(); + const loadedSeeders = getSeeders(dbSeeders); + const loadedSeedsCountMap = getSeedsCountMap(); + const loadedPresetsMap = getExtraPresetsMap(); + const [ + baselinePreset, + setBaselinePreset, + ] = React.useState(loadedBaselinePreset); + const [extraPresets, setExtraPresets] = React.useState( + loadedExtraPresets + ); + const [presetsCountMap, setPresetsCountMap] = React.useState<{ + [key: string]: number; + }>(loadedPresetsMap); + const [seeders, setSeeders] = React.useState(loadedSeeders); + const [seedsCountMap, setSeedsCountMap] = React.useState<{ + [key: string]: number; + }>(loadedSeedsCountMap); + const isCrudPreset = + loadedBaselinePreset === 'baseline:crud' || + baselinePreset === 'baseline:crud'; + + const [saveState, setSaveState] = React.useState({ + hasSaved: false, + hasUnsavedChanges: false, + mocksCleared: false, + }); + + const globalHandlers = { + applyChanges: () => { + // Save base preset, extra presets, and content seeders to local storage. + saveBaselinePreset(baselinePreset); + saveExtraPresets(extraPresets); + saveSeeders(seeders); + saveSeedsCountMap(seedsCountMap); + saveExtraPresetsMap(presetsCountMap); + + const promises = seeders.map((seederId) => { + const seeder = dbSeeders.find((dbSeeder) => dbSeeder.id === seederId); + + return seeder?.seeder({} as MockState); + }); + + Promise.all(promises).then(() => { + setSaveState((prevSaveState) => ({ + ...prevSaveState, + hasSaved: true, + hasUnsavedChanges: false, + })); + }); + + // We only have to reload the window if MSW is already enabled. Otherwise, + // the changes will automatically be picked up next time MSW is enabled. + if (isMSWEnabled) { + window.location.reload(); + } + }, + + discardChanges: () => { + setBaselinePreset(getBaselinePreset()); + setExtraPresets(getExtraPresets()); + setSeeders(getSeeders(dbSeeders)); + setSeedsCountMap(getSeedsCountMap()); + setPresetsCountMap(getExtraPresetsMap()); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: false, + }); + }, + + resetAll: () => { + mswDB.clear('mockState'); + mswDB.clear('seedState'); + seederHandlers.removeAll(); + setBaselinePreset('baseline:preset-mocking'); + setExtraPresets([]); + setPresetsCountMap({}); + saveBaselinePreset('baseline:preset-mocking'); + saveExtraPresets([]); + saveSeeders([]); + saveSeedsCountMap({}); + saveExtraPresetsMap({}); + + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + mocksCleared: true, + }); + }, + + toggleMSW: (e: React.ChangeEvent) => { + saveMSWEnabled(e.target.checked); + window.location.reload(); + }, + }; + + const seederHandlers = { + changeCount: ( + e: React.ChangeEvent, + seederId: MockPresetCrudId + ) => { + setSeedsCountMap({ + ...seedsCountMap, + [seederId]: parseInt(e.target.value, 10), + }); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + }, + + removeAll: () => { + // remove all seeds & reset all seed fields to 0 + setSeeders([]); + setSeedsCountMap( + Object.fromEntries(Object.keys(seedsCountMap).map((key) => [key, 0])) + ); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + }, + + toggle: async ( + e: React.ChangeEvent, + seederId: MockPresetCrudId + ) => { + const willEnable = e.target.checked; + if (willEnable && !seeders.includes(seederId)) { + setSeeders([...seeders, seederId]); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + } else if (!willEnable && seeders.includes(seederId)) { + setSeeders( + seeders.filter((seeder) => { + return seeder !== seederId; + }) + ); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + await removeSeeds(seederId); + } + }, + }; + + const presetHandlers = { + changeBase: (e: React.ChangeEvent) => { + setBaselinePreset(e.target.value as MockPresetBaselineId); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + }, + + changeCount: ( + e: React.ChangeEvent, + presetId: MockPresetBaselineId + ) => { + setPresetsCountMap({ + ...presetsCountMap, + [presetId]: parseInt(e.target.value, 10), + }); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + }, + + changeSelect: ( + e: React.ChangeEvent, + groupId: MockPresetExtraGroup['id'] + ) => { + const newPresetId = e.target.value; + + const updatedExtraPresets = extraPresets.filter((presetId) => { + const preset = extraMockPresets.find((p) => p.id === presetId); + + return preset?.group.id !== groupId; + }); + + // Add the new preset if one was selected + if (newPresetId) { + updatedExtraPresets.push(newPresetId); + } + + setExtraPresets(updatedExtraPresets); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + }, + + toggle: ( + e: React.ChangeEvent, + presetId: MockPresetExtraId + ) => { + const willEnable = e.target.checked; + if (willEnable && !extraPresets.includes(presetId)) { + setExtraPresets([...extraPresets, presetId]); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + } else if (!willEnable && extraPresets.includes(presetId)) { + setExtraPresets( + extraPresets.filter((handler) => { + return handler !== presetId; + }) + ); + setSaveState({ + hasSaved: false, + hasUnsavedChanges: true, + }); + } + }, + }; + return ( - <> - - Mock Service Worker: - - {isMSWEnabled ? 'Enabled' : 'Disabled'} - - - setMSWEnabled(e.target.checked)} - style={{ margin: 0 }} - type="checkbox" - /> - +
    +
    + API Mocks +
    + +
    +
    +
    + globalHandlers.toggleMSW(e)} + style={{ margin: 0 }} + type="checkbox" + /> + + Enable MSW + +
    +
    + + Base Preset + + presetHandlers.changeBase(e)} + value={baselinePreset} + > + + +
    +
    +
    +
    +
    + Seeds (CRUD preset only) + +
    +
    +
    + +
    +
    +
    +
    +
    Presets
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + + + +
    +
    +
    ); }; diff --git a/packages/manager/src/dev-tools/components/BaselinePresetOptions.tsx b/packages/manager/src/dev-tools/components/BaselinePresetOptions.tsx new file mode 100644 index 00000000000..f8cca0420fd --- /dev/null +++ b/packages/manager/src/dev-tools/components/BaselinePresetOptions.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; + +import { getMockPresetGroups } from 'src/mocks/mockPreset'; +import { baselineMockPresets } from 'src/mocks/presets'; + +/** + * Renders a select with options for baseline presets + */ +export const BaselinePresetOptions = () => { + return getMockPresetGroups(baselineMockPresets).map((group) => ( + + {baselineMockPresets + .filter((mockPreset) => mockPreset.group.id === group) + .map((mockPreset) => { + return ( + + ); + })} + + )); +}; diff --git a/packages/manager/src/dev-tools/components/DevToolSelect.tsx b/packages/manager/src/dev-tools/components/DevToolSelect.tsx new file mode 100644 index 00000000000..ae63f870a31 --- /dev/null +++ b/packages/manager/src/dev-tools/components/DevToolSelect.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; + +export type DevToolsSelectProps = React.DetailedHTMLProps< + React.SelectHTMLAttributes, + HTMLSelectElement +>; + +/** + * Thin wrapper around native `{props.children} +
    + ); +}; diff --git a/packages/manager/src/dev-tools/components/Draggable.tsx b/packages/manager/src/dev-tools/components/Draggable.tsx new file mode 100644 index 00000000000..3eb31c5cfbe --- /dev/null +++ b/packages/manager/src/dev-tools/components/Draggable.tsx @@ -0,0 +1,133 @@ +import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; +import React, { useEffect, useRef, useState } from 'react'; + +import ResizeWindow from 'src/assets/icons/ResizeWindow.svg'; + +import type { ReactNode } from 'react'; + +interface DraggableProps { + children?: ReactNode; + draggable: boolean; +} + +export const Draggable = ({ children, draggable }: DraggableProps) => { + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [position, setPosition] = useState({ + x: 40, + y: window.innerHeight - 400, + }); + const [size, setSize] = useState({ height: 400, width: 600 }); + const [rel, setRel] = useState<{ x: number; y: number } | null>(null); + const nodeRef = useRef(null); + const minWidth = 400; + const minHeight = 300; + + const onMouseDown = (e: React.MouseEvent) => { + if (!draggable || e.button !== 0) { + return; + } + + const node = nodeRef.current; + if (!node) { + return; + } + + const rect = node.getBoundingClientRect(); + setIsDragging(true); + setRel({ + x: e.pageX - rect.left, + y: e.pageY - rect.top, + }); + e.stopPropagation(); + e.preventDefault(); + }; + + const onMouseMove = React.useCallback( + (e: MouseEvent) => { + if (isDragging) { + setPosition({ + x: e.pageX - (rel?.x || 0), + y: e.pageY - (rel?.y || 0), + }); + } else if (isResizing) { + const newSize = { + height: Math.max(e.pageY - position.y, minHeight) + 30, + width: Math.max(e.pageX - position.x, minWidth) + 10, + }; + setSize(newSize); + } + e.stopPropagation(); + e.preventDefault(); + }, + [isDragging, isResizing, position, rel] + ); + + const onMouseUp = React.useCallback((e: MouseEvent) => { + setIsDragging(false); + setIsResizing(false); + e.stopPropagation(); + e.preventDefault(); + }, []); + + const onResizeStart = (e: React.MouseEvent) => { + setIsResizing(true); + e.stopPropagation(); + e.preventDefault(); + }; + + useEffect(() => { + if (isDragging || isResizing) { + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + } else { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + } + + return () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + }, [isDragging, isResizing, onMouseMove, onMouseUp]); + + return ( +
    { + if (e.key === 'Enter' || e.key === ' ') { + onMouseDown((e as unknown) as React.MouseEvent); + } + }} + style={{ + height: `${size.height}px`, + left: `${position.x}px`, + position: 'absolute', + top: `${position.y}px`, + width: `${size.width}px`, + }} + ref={nodeRef} + role="button" + tabIndex={0} + > + {children} + {draggable && ( + <> + + + + )} +
    + ); +}; diff --git a/packages/manager/src/dev-tools/components/ExtraPresetOptionCheckbox.tsx b/packages/manager/src/dev-tools/components/ExtraPresetOptionCheckbox.tsx new file mode 100644 index 00000000000..9eedf8d709b --- /dev/null +++ b/packages/manager/src/dev-tools/components/ExtraPresetOptionCheckbox.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; + +import { extraMockPresets } from 'src/mocks/presets'; + +import type { ExtraPresetOptionsProps } from './ExtraPresetOptions'; + +interface ExtraPresetOptionCheckboxProps + extends Omit { + group: string; +} + +export const ExtraPresetOptionCheckbox = ( + props: ExtraPresetOptionCheckboxProps +) => { + const { + disabled, + group, + handlers, + onPresetCountChange, + onTogglePreset, + presetsCountMap, + } = props; + + return extraMockPresets + .filter((extraMockPreset) => extraMockPreset.group.id === group) + .map( + (extraMockPreset) => + extraMockPreset.group.type === 'checkbox' && ( +
  • +
    + onTogglePreset(e, extraMockPreset.id)} + type="checkbox" + /> + + {extraMockPreset.label} + +
    + {extraMockPreset.canUpdateCount && ( +
    + + + + + { + if (e.target.value === '') { + e.target.value = '0'; + } + }} + onChange={(e) => { + const value = e.target.value; + onPresetCountChange( + { + target: { value }, + } as React.ChangeEvent, + extraMockPreset.id + ); + }} + onFocus={(e) => { + if (e.target.value === '0') { + e.target.value = ''; + } + }} + value={ + presetsCountMap[extraMockPreset.id] ?? + (presetsCountMap[extraMockPreset.id] || '0') + } + aria-label={`Value for ${extraMockPreset.label}`} + disabled={disabled || !handlers.includes(extraMockPreset.id)} + min={0} + style={{ paddingLeft: '20px', width: '100%' }} + type="number" + /> +
    + )} +
  • + ) + ); +}; diff --git a/packages/manager/src/dev-tools/components/ExtraPresetOptionSelect.tsx b/packages/manager/src/dev-tools/components/ExtraPresetOptionSelect.tsx new file mode 100644 index 00000000000..3b07094474c --- /dev/null +++ b/packages/manager/src/dev-tools/components/ExtraPresetOptionSelect.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; + +import { extraMockPresets } from 'src/mocks/presets'; + +import type { ExtraPresetOptionsProps } from './ExtraPresetOptions'; + +interface ExtraPresetOptionCheckboxProps + extends Omit< + ExtraPresetOptionsProps, + 'onPresetCountChange' | 'onTogglePreset' + > { + group: string; +} + +export const ExtraPresetOptionSelect = ( + props: ExtraPresetOptionCheckboxProps +) => { + const { disabled, group, handlers, onSelectChange } = props; + + return ( +
    + +
    + ); +}; diff --git a/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx b/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx new file mode 100644 index 00000000000..6a56f0f650c --- /dev/null +++ b/packages/manager/src/dev-tools/components/ExtraPresetOptions.tsx @@ -0,0 +1,72 @@ +import * as React from 'react'; + +import { getMockPresetGroups } from 'src/mocks/mockPreset'; +import { extraMockPresets } from 'src/mocks/presets'; + +import { ExtraPresetOptionCheckbox } from './ExtraPresetOptionCheckbox'; +import { ExtraPresetOptionSelect } from './ExtraPresetOptionSelect'; + +export interface ExtraPresetOptionsProps { + disabled: boolean; + handlers: string[]; + onPresetCountChange: (e: React.ChangeEvent, presetId: string) => void; + onSelectChange: (e: React.ChangeEvent, presetId: string) => void; + onTogglePreset: (e: React.ChangeEvent, presetId: string) => void; + presetsCountMap: { [key: string]: number }; +} + +/** + * Renders a list of extra presets with an optional count. + */ +export const ExtraPresetOptions = ({ + disabled, + handlers, + onPresetCountChange, + onSelectChange, + onTogglePreset, + presetsCountMap, +}: ExtraPresetOptionsProps) => { + const extraPresetGroups = getMockPresetGroups(extraMockPresets); + + return ( +
      + {extraPresetGroups.map((group) => { + const currentGroupType = extraMockPresets.find( + (p) => p.group.id === group + )?.group.type; + + const selectGroup = currentGroupType === 'select'; + + return ( +
      +
    • + {group}{' '} + {currentGroupType === 'select' && ( + + )} +
    • + {currentGroupType === 'checkbox' && ( + + )} +
      + ); + })} +
    + ); +}; diff --git a/packages/manager/src/dev-tools/components/SeedOptions.tsx b/packages/manager/src/dev-tools/components/SeedOptions.tsx new file mode 100644 index 00000000000..667a46cbd13 --- /dev/null +++ b/packages/manager/src/dev-tools/components/SeedOptions.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; + +import { getStateSeederGroups } from 'src/mocks/mockState'; +import { dbSeeders } from 'src/mocks/presets/crud/seeds'; + +interface SeedOptionsProps { + disabled: boolean; + onCountChange: (e: React.ChangeEvent, populatorId: string) => void; + onToggleSeeder: (e: React.ChangeEvent, populatorId: string) => void; + seeders: string[]; + seedsCountMap: { [key: string]: number }; +} + +/** + * Renders a list of seeders and their counts. + */ +export const SeedOptions = ({ + disabled, + onCountChange, + onToggleSeeder, + seeders, + seedsCountMap, +}: SeedOptionsProps) => { + return ( +
      + {getStateSeederGroups(dbSeeders).map((group) => ( +
      + {dbSeeders + .filter((dbSeeder) => dbSeeder.group.id === group) + .map((dbSeeder) => ( +
    • + onToggleSeeder(e, dbSeeder.id)} + style={{ marginRight: 12 }} + type="checkbox" + /> + + {dbSeeder.label} + + {dbSeeder.canUpdateCount && ( + { + const value = e.target.value; + if (value === '') { + e.target.value = '0'; + } + }} + onChange={(e) => { + const value = e.target.value; + onCountChange( + { + target: { value }, + } as React.ChangeEvent, + dbSeeder.id + ); + }} + onFocus={(e) => { + if (e.target.value === '0') { + e.target.value = ''; + } + }} + aria-label={`Value for ${dbSeeder.label}`} + disabled={disabled || !seeders.includes(dbSeeder.id)} + min={0} + style={{ marginLeft: 8, width: 60 }} + type="number" + value={seedsCountMap[dbSeeder.id] || '0'} + /> + )} +
    • + ))} +
      + ))} +
    + ); +}; diff --git a/packages/manager/src/dev-tools/constants.ts b/packages/manager/src/dev-tools/constants.ts new file mode 100644 index 00000000000..2370a42a329 --- /dev/null +++ b/packages/manager/src/dev-tools/constants.ts @@ -0,0 +1,11 @@ +export const LOCAL_STORAGE_KEY = 'msw'; + +export const LOCAL_STORAGE_SEEDERS_KEY = 'msw-seeders'; + +export const LOCAL_STORAGE_PRESET_KEY = 'msw-preset'; + +export const LOCAL_STORAGE_PRESET_EXTRAS_KEY = 'msw-preset-extras'; + +export const LOCAL_STORAGE_SEEDS_COUNT_MAP_KEY = 'msw-seeds-count-map'; + +export const LOCAL_STORAGE_PRESETS_MAP_KEY = 'msw-preset-count-map'; diff --git a/packages/manager/src/dev-tools/dev-tools.css b/packages/manager/src/dev-tools/dev-tools.css index 23f3b0a5a28..1793a41a38b 100644 --- a/packages/manager/src/dev-tools/dev-tools.css +++ b/packages/manager/src/dev-tools/dev-tools.css @@ -1,42 +1,536 @@ -#dev-tools { +.dev-tools { + --open-height-desktop: 375px; + --open-height-mobile: 400px; + + --background-color-closed: rgba(0, 0, 0, 0.55); + --background-color-opened: rgba(0, 0, 0, 0.85); + --background-blur: blur(7px); + + --open-close-transition: background-color 0.2s, height 0.075s; + position: fixed; - bottom: 0px; - background: black; - opacity: 0.4; + width: 100%; + bottom: 0; + left: 0; + z-index: 1; + color: white; + font-size: 11pt; + scrollbar-color: rgba(255, 255, 255, .75) transparent; + + &.isDraggable { + position: relative; + height: calc(100% - 50px); + + .dev-tools__main { + flex-direction: column; + } + + .dev-tools__tool { + min-height: 300px; + } + + .dev-tools__tool__body, + .dev-tools__msw__column__body { + flex-basis: auto; + } + + .dev-tools__body { + border-radius: 0 0 4px 4px; + height: 100% !important; + } + } +} + +.dev-tools__draggable-handle { + position: absolute; + z-index: 2; + left: -30px; + top: 35px; + cursor: grab; + background: #222222; + border: none; + border-radius: 4px 0 0 4px; + padding: 4px 4px 2px 4px; + + svg { + fill: white; + } +} + +.dev-tools__resize-handle { + position: absolute; + z-index: 2; + right: 0px; + bottom: 16px; + color: white; + cursor: ew-resize; + background: transparent; + border: none; + + svg { + fill: white; + } +} + +.dev-tools hr { + border: none; + height: 1px; + background-color: rgba(255, 255, 255, 0.25); + margin-top: 12px; + margin-bottom: 16px; +} + +.dev-tools__select { + position: relative; + display: inline-block; + background: transparent; + border-radius: 4px; + border: 2px solid rgba(255, 255, 255, 0.5); + color: #fff; + + &.thin { + border-width: 1px + } +} + +.dev-tools__select:has(>select:focus), +.dev-tools__select:has(>select:active) { + background: rgb(50, 50, 50); + border-color: rgba(255, 255, 255, 0.65); +} + +.dev-tools__select::after { + position: absolute; + right: 8px; + top: 10px; + content: ''; + display: block; + width: 6px; + height: 6px; + border-right: 2px solid white; + border-top: 2px solid white; + transform: rotate(135deg); +} + +.dev-tools__select select { + background: transparent; + border: none; color: white; + padding: 4px 4px 5px 6px; + font-family: inherit; + font-size: 10.5pt; + appearance: none; + outline: none; +} + + /* avoid overriding TanStack React Query Devtools styles */ +.dev-tools__body button:not(.tsqd-parent-container button) { + background: transparent; + border: 2px solid rgba(255, 255, 255, 0.5); + border-radius: 1000px; + color: white; + cursor: pointer; + padding: 5px 18px 7px 18px; + font-weight: bold; + font-size: 10.5pt; + font-family: inherit; + transition: background-color 0.2s; + white-space: nowrap; + + &.small { + padding: 2px 8px 4px 8px; + border-width: 1px; + font-size: 12px; + } + + &.right-align { + float: right; + } + + + &.green { + background: #17cf73; + color: #080808; + } +} + +.dev-tools__body .dev-tools__content button:hover { + background: rgba(255, 255, 255, 0.1); +} + +.dev-tools__body .dev-tools__content button:not(:disabled).green:hover { + background-color: #60e9a4; + color: #080808; + +} + +.dev-tools__body .dev-tools__content button:disabled, +.dev-tools__body .dev-tools__content button:disabled:active { + background: transparent; + cursor: not-allowed; + border-color: rgba(255, 255, 255, 0.3); + color: rgba(255, 255, 255, 0.25); +} + +.dev-tools__segmented-button .dev-tools__content button:not(:only-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-left: 1px solid rgba(255, 255, 255, 0.5); + border-right: 1px solid rgba(255, 255, 255, 0.5); +} + +.dev-tools__segmented-button { + display: flex; + flex-wrap: nowrap; + + button { + white-space: nowrap; + } +} + +.dev-tools__segmented-button .dev-tools__content button:first-child:not(:only-child) { + border-top-left-radius: 1000px; + border-bottom-left-radius: 1000px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-left: 2px solid rgba(255, 255, 255, 0.5); + border-right: 1px solid rgba(255, 255, 255, 0.5); + padding-left: 24px; +} + +.dev-tools__segmented-button .dev-tools__content button:last-child:not(:only-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-top-right-radius: 1000px; + border-bottom-right-radius: 1000px; + border-left: 1px solid rgba(255, 255, 255, 0.5); + border-right: 2px solid rgba(255, 255, 255, 0.5); + padding-right: 24px; +} + +.dev-tools__button-list { width: 100%; - padding: 20px; - height: 60px; - width: 60px; - transition: all 0.3s; + display: flex; + gap: 8px; + justify-content: end; +} + +.dev-tools__body .dev-tools__content button.toggle-button.toggle-button--on { + background: rgba(255, 255, 255, 0.2); + border: 2px solid white; + border-right: 2px solid white; + border-left: 2px solid white; + border-color: white; + border-left-color: white; + border-right-color: white; + border-top-color: white; + border-bottom-color: white; + color: white; + text-shadow: 0px -1px 0px black; +} + +.dev-tools__body .dev-tools__content button:active { + background: rgb(50, 50, 50); + border-color: rgba(255, 255, 255, 0.65); +} + +.dev-tools__toggle { + width: 70px; + height: 54px; + border-top-right-radius: 12px; + position: absolute; + left: 0; + bottom: 0; z-index: 1; } -#dev-tools.mswEnabled svg { - color: #aaff00; +.dev-tools__toggle button { + width: 100%; + height: 100%; + background-color: transparent; + border: 0; + color: white; + cursor: pointer; +} + +.dev-tools__toggle button svg { + position: relative; + top: 3px; + left: -5px; } -#dev-tools:hover { - height: 375px; +.dev-tools.dev-tools--msw .dev-tools__toggle button svg { + color: #17cf73; + filter: drop-shadow(0 0 3px limegreen); +} + +.dev-tools__toggle button::after { + content: ''; + width: 8px; + height: 8px; + border-right: 2px solid white; + border-top: 2px solid white; + display: inline-block; + position: relative; + left: 3px; + top: -3px; + transform: rotate(-45deg); + transition: 0.3s transform; +} + +.dev-tools.dev-tools--open .dev-tools__toggle button::after { + transform: translateY(-4px) rotate(135deg); +} + +.dev-tools .dev-tools__toggle, +.dev-tools .dev-tools__body { + background-color: var(--background-color-closed); + backdrop-filter: var(--background-blur); + transition: var(--open-close-transition); +} + +.dev-tools.dev-tools--open .dev-tools__toggle { + background-color: #080808; + backdrop-filter: none; +} + +.dev-tools__draggable-toggle { + display: flex; + justify-content: end; + + button { + background: #1d1d1e; + background-blend-mode: overlay; + border: 0; + + cursor: pointer; + padding: 4px; + position: relative; + bottom: -4px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } + + svg { + color: white; + + } +} + +.dev-tools.dev-tools--open .dev-tools__body { + background-color: var(--background-color-opened); +} + +.dev-tools__body input[type="number"] { + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.5); + font-family: inherit; + font-size: 10.5pt; + float: right; +} + +/* + * Dev tools body has height of `0` when `.dev-tools--open` class is not present. + */ +.dev-tools .dev-tools__body { + height: 0; + padding: 0; +} +.dev-tools.dev-tools--open .dev-tools__body { + height: var(--open-height-mobile); +} + +.dev-tools__content { + padding: 12px 12px; + display: flex; + flex-direction: column; + gap: 12px; width: 100%; - opacity: 0.9; + height: 100%; +} + +.dev-tools__status-bar { + flex-grow: 0; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: nowrap; + overflow-x: auto; + gap: 12px; +} + +.dev-tools__main { + display: flex; + flex-direction: column; + flex-basis: 0; + flex-grow: 1; + flex-shrink: 1; overflow: auto; + gap: 24px; } -#dev-tools:hover svg { - color: white; +.dev-tools__main__column { + flex-basis: 0; + flex-shrink: 0; + flex-grow: 1; } -#dev-tools .tools { - display: none; +.dev-tools__tool { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + gap: 12px; } -#dev-tools:hover .tools { +.dev-tools__tool__header { + flex-grow: 0; + flex-shrink: 0; + flex-basis: 0; +} + +.dev-tools__tool__header span { + font-size: 13pt; + font-weight: bold; +} + +.dev-tools__tool__footer { + flex-grow: 0; + flex-shrink: 0; + flex-basis: 0; + align-items: center; +} + +.dev-tools__list-box, +.dev-tools__scroll-box { + /*padding: 4px 8px;*/ + border: 1px solid rgba(255, 255, 255, 0.25); + box-shadow: inset 0px 5px 9px 0px rgba(0,0,0,0.35); + height: 100%; + overflow: auto; +} + +.dev-tools__list-box ul { + padding-left: none; + padding: 4px 8px; + /*padding-left: 0;*/ + margin: 0; + height: 100%; +} + +.dev-tools__list-box li { + padding-top: 3px; + padding-bottom: 4px; + margin-top: 2px; + margin-bottom: 2px; + list-style: none; +} + +.dev-tools__list-box li:nth-child(even) { + background-color: rgba(255, 255, 255, 0.1); + border-radius: 4px; + margin-left: -4px; + margin-right: -4px; + padding-left: 4px; + padding-right: 4px; +} + +.dev-tools .dev-tools__list-box .dev-tools__list-box__separator { + border-bottom: 1px solid rgba(255, 255, 255, 0.25); + margin-left: -8px; + margin-right: -8px; + padding-left: 8px; + padding-right: 8px; + padding-bottom: 9px; + margin-bottom: 6px; + font-weight: bold; + background-color: transparent; + border-radius: 0; +} + +.dev-tools__msw { + display: flex; + flex-direction: column; + gap: 12px; +} + +.dev-tools__msw__presets { display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.25); } -@media only screen and (max-width: 960px) { - #dev-tools:hover .tools { - justify-content: space-between; +.dev-tools__msw__extras { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 12px; + + &.disabled { + opacity: 0.5; } } + +.dev-tools__msw__column__heading { + flex-basis: 0; + flex-grow: 0; + flex-shrink: 0; + font-weight: bold; + + &.disabled { + opacity: 0.5; + } +} + + + +@media only screen and (min-width: 1024px) { + .dev-tools.dev-tools--open .dev-tools__body { + height: var(--open-height-desktop); + } + + .dev-tools__main { + flex-direction: row; + } + + .dev-tools__msw__extras { + flex-direction: row; + } + + .dev-tools__tool__body { + flex-grow: 1; + flex-shrink: 1; + flex-basis: 0; + overflow: auto; + } + + .dev-tools__msw__column { + flex-grow: 1; + flex-shrink: 1; + flex-basis: 0; + height: 100%; + min-height: 120px; + display: flex; + flex-direction: column; + gap: 4px; + } + + .dev-tools__msw__column__body { + flex-basis: 0; + flex-grow: 1; + flex-shrink: 1; + overflow: auto; + } +} + +/* TanStack React Query Devtools */ +.tsqd-main-panel { + height: 100% !important; + width: 100% !important; + position: relative !important; +} \ No newline at end of file diff --git a/packages/manager/src/dev-tools/dev-tools.tsx b/packages/manager/src/dev-tools/dev-tools.tsx index 531961e1fe2..c91715ac618 100644 --- a/packages/manager/src/dev-tools/dev-tools.tsx +++ b/packages/manager/src/dev-tools/dev-tools.tsx @@ -1,56 +1,186 @@ +import CloseIcon from '@mui/icons-material/Close'; import Handyman from '@mui/icons-material/Handyman'; -import Grid from '@mui/material/Unstable_Grid2'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import { styled } from '@mui/material'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import React from 'react'; -import { createRoot } from 'react-dom/client'; import { Provider } from 'react-redux'; -import { ENABLE_DEV_TOOLS, isProductionBuild } from 'src/constants'; -import { ApplicationStore } from 'src/store'; +import { getRoot } from 'src/utilities/rootManager'; +import { Draggable } from './components/Draggable'; import './dev-tools.css'; import { EnvironmentToggleTool } from './EnvironmentToggleTool'; import { FeatureFlagTool } from './FeatureFlagTool'; -import { MockDataTool } from './MockDataTool'; -import { Preferences } from './Preferences'; -import { isMSWEnabled } from './ServiceWorkerTool'; -import { ThemeSelector } from './ThemeSelector'; +import { ServiceWorkerTool } from './ServiceWorkerTool'; +import { isMSWEnabled } from './utils'; + +import type { QueryClient } from '@tanstack/react-query'; +import type { ApplicationStore } from 'src/store'; + +export type DevToolsView = 'mocks' | 'react-query'; + +const reactQueryDevtoolsStyle = { + border: '1px solid rgba(255, 255, 255, 0.25)', + height: '100%', + width: '100%', +}; + +export const install = (store: ApplicationStore, queryClient: QueryClient) => { + const DevTools = () => { + const [isOpen, setIsOpen] = React.useState(false); + const [isDraggable, setIsDraggable] = React.useState(false); + const [view, setView] = React.useState('mocks'); + const devToolsMainRef = React.useRef(null); + + const handleOpenReactQuery = () => { + setView('react-query'); + }; + + const handleOpenMocks = () => { + setView('mocks'); + }; + + const handleDraggableToggle = () => { + setIsDraggable(!isDraggable); + if (isDraggable) { + setIsOpen(false); + } + }; + + const handleGoToPreferences = () => { + window.location.assign('/profile/settings?preferenceEditor=true'); + }; + + React.useEffect(() => { + // Prevent scrolling of the window when scrolling start/end of the dev tools + // Particularly useful when in draggable mode + if (!isDraggable) { + return; + } + + const handleWheel = (e: WheelEvent) => { + if (!devToolsMainRef.current?.contains(e.target as Node)) { + return; + } + + const target = devToolsMainRef.current; + const isAtTop = target.scrollTop === 0; + const isAtBottom = + target.scrollHeight - target.clientHeight <= target.scrollTop + 1; + + if ((isAtTop && e.deltaY < 0) || (isAtBottom && e.deltaY > 0)) { + e.preventDefault(); + } + }; + + window.addEventListener('wheel', handleWheel, { passive: false }); + + return () => { + window.removeEventListener('wheel', handleWheel); + }; + }, []); -function install(store: ApplicationStore) { - function DevTools() { return ( -
    -
    - -
    - - - - - {import.meta.env.DEV && ( - - - + +
    + {!isDraggable && ( +
    + +
    + )} + {isOpen && ( +
    + +
    )} - {!isProductionBuild || ENABLE_DEV_TOOLS ? ( - - - - - - ) : null} - -
    +
    +
    +
    +
    + +
    +
    + + +
    +
    + +
    +
    +
    + {view === 'mocks' && ( + <> +
    + +
    +
    + +
    + + )} + {view === 'react-query' && ( + + + + + + )} +
    +
    +
    +
    + ); - } + }; + + const devToolsRoot = + document.getElementById('dev-tools-root') || + (() => { + const newRoot = document.createElement('div'); + newRoot.id = 'dev-tools-root'; + document.body.appendChild(newRoot); + return newRoot; + })(); - const devToolsRoot = document.createElement('div'); - document.body.appendChild(devToolsRoot); - const root = createRoot(devToolsRoot); + const root = getRoot(devToolsRoot); root.render( ); -} +}; -export { install }; +const StyledReactQueryDevtoolsContainer = styled('div')({ + border: '1px solid rgba(255, 255, 255, 0.25)', + height: '100%', + width: '100%', +}); diff --git a/packages/manager/src/dev-tools/load.ts b/packages/manager/src/dev-tools/load.ts index 9b0703ff810..6d34f875c2d 100644 --- a/packages/manager/src/dev-tools/load.ts +++ b/packages/manager/src/dev-tools/load.ts @@ -1,23 +1,135 @@ import { ENABLE_DEV_TOOLS } from 'src/constants'; -import { ApplicationStore } from 'src/store'; +import { mswDB } from 'src/mocks/indexedDB'; +import { resolveMockPreset } from 'src/mocks/mockPreset'; +import { createInitialMockStore, emptyStore } from 'src/mocks/mockState'; +import { allMockPresets, defaultBaselineMockPreset } from 'src/mocks/presets'; +import { dbSeeders } from 'src/mocks/presets/crud/seeds'; -import { isMSWEnabled } from './ServiceWorkerTool'; +import { + getBaselinePreset, + getExtraPresets, + getSeeders, + isMSWEnabled, +} from './utils'; + +import type { QueryClient } from '@tanstack/react-query'; +import type { MockPresetExtra, MockSeeder, MockState } from 'src/mocks/types'; +import type { ApplicationStore } from 'src/store'; + +export let mockState: MockState; /** - * Use this to dynamicly import our custom dev-tools ONLY when they + * Use this to dynamically import our custom dev-tools ONLY when they * are needed. + * * @param store Redux store to control */ -export async function loadDevTools(store: ApplicationStore) { +export async function loadDevTools( + store: ApplicationStore, + client: QueryClient +) { const devTools = await import('./dev-tools'); if (isMSWEnabled) { - const { worker } = await import('../mocks/testBrowser'); + const { worker: mswWorker } = await import('../mocks/mswWorkers'); + const mswPresetId = getBaselinePreset() ?? defaultBaselineMockPreset.id; + const mswPreset = + allMockPresets.find((preset) => preset.id === mswPresetId) ?? + defaultBaselineMockPreset; + + const extraMswPresetIds = getExtraPresets(); + const extraMswPresets = extraMswPresetIds + .map((presetId) => + allMockPresets.find((extraPreset) => extraPreset.id === presetId) + ) + .filter((preset) => !!preset); + + const mswContentSeederIds = getSeeders(dbSeeders); + const mswContentSeeders = mswContentSeederIds + .map((seederId) => dbSeeders.find((dbSeeder) => dbSeeder.id === seederId)) + .filter((seeder) => !!seeder); + + // Apply MSW context populators. + const initialContext = await createInitialMockStore(); + await mswDB.saveStore(initialContext, 'mockState'); + + // Seeding + const seedContext = (await mswDB.getStore('seedState')) || emptyStore; + + const populateSeeds = async (store: MockState): Promise => { + return await mswContentSeeders.reduce( + async (accPromise, cur: MockSeeder) => { + const acc = await accPromise; + + return await cur.seeder(acc); + }, + Promise.resolve(store) + ); + }; + + const updateSeedContext = async ( + key: T, + seeds: MockState + ): Promise => { + seedContext[key] = seeds[key]; + }; + + const seeds = await populateSeeds(emptyStore); + + const seedPromises = (Object.keys( + seedContext + ) as (keyof MockState)[]).map((key) => updateSeedContext(key, seeds)); + + await Promise.all(seedPromises); + + await mswDB.saveStore(seedContext ?? emptyStore, 'seedState'); + + // Merge the contexts + const mergedContext: MockState = { + ...initialContext, + eventQueue: [ + ...initialContext.eventQueue, + ...(seedContext?.eventQueue || []), + ], + firewalls: [ + ...initialContext.firewalls, + ...(seedContext?.firewalls || []), + ], + linodeConfigs: [ + ...initialContext.linodeConfigs, + ...(seedContext?.linodeConfigs || []), + ], + linodes: [...initialContext.linodes, ...(seedContext?.linodes || [])], + notificationQueue: [ + ...initialContext.notificationQueue, + ...(seedContext?.notificationQueue || []), + ], + placementGroups: [ + ...initialContext.placementGroups, + ...(seedContext?.placementGroups || []), + ], + regionAvailability: [ + ...initialContext.regionAvailability, + ...(seedContext?.regionAvailability || []), + ], + regions: [...initialContext.regions, ...(seedContext?.regions || [])], + volumes: [...initialContext.volumes, ...(seedContext?.volumes || [])], + }; + + const extraHandlers = extraMswPresets.reduce( + (acc, cur: MockPresetExtra) => { + return [...resolveMockPreset(cur, mergedContext), ...acc]; + }, + [] + ); + + const baseHandlers = resolveMockPreset(mswPreset, mergedContext); + const worker = mswWorker(extraHandlers, baseHandlers); await worker.start({ onUnhandledRequest: 'bypass' }); } - devTools.install(store); + devTools.install(store, client); } /** @@ -26,5 +138,5 @@ export async function loadDevTools(store: ApplicationStore) { * * Define `REACT_APP_ENABLE_DEV_TOOLS` to explicitly enable or disable dev tools */ -export const shouldEnableDevTools = +export const shouldLoadDevTools = ENABLE_DEV_TOOLS !== undefined ? ENABLE_DEV_TOOLS : import.meta.env.DEV; diff --git a/packages/manager/src/dev-tools/utils.ts b/packages/manager/src/dev-tools/utils.ts new file mode 100644 index 00000000000..6335041c8f3 --- /dev/null +++ b/packages/manager/src/dev-tools/utils.ts @@ -0,0 +1,148 @@ +import { defaultBaselineMockPreset, extraMockPresets } from 'src/mocks/presets'; + +import { + LOCAL_STORAGE_KEY, + LOCAL_STORAGE_PRESET_EXTRAS_KEY, + LOCAL_STORAGE_PRESET_KEY, + LOCAL_STORAGE_PRESETS_MAP_KEY, + LOCAL_STORAGE_SEEDERS_KEY, + LOCAL_STORAGE_SEEDS_COUNT_MAP_KEY, +} from './constants'; + +import type { + MockPresetBaselineId, + MockPresetExtraId, + MockSeeder, +} from 'src/mocks/types'; + +/** + * Whether MSW is enabled via local storage setting. + * + * `true` if MSW is enabled, `false` otherwise. + */ +export const isMSWEnabled = + localStorage.getItem(LOCAL_STORAGE_KEY) === 'enabled'; + +/** + * Saves MSW enabled or disabled state to local storage. + * + * @param enabled - Whether or not to enable MSW. + */ +export const saveMSWEnabled = (enabled: boolean): void => { + localStorage.setItem(LOCAL_STORAGE_KEY, enabled ? 'enabled' : 'disabled'); +}; + +/** + * Returns the ID of the selected MSW preset that is stored in local storage. + * + * @returns ID of selected MSW preset, or `null` if no preset is saved. + */ +export const getBaselinePreset = (): MockPresetBaselineId => { + return ( + (localStorage.getItem(LOCAL_STORAGE_PRESET_KEY) as MockPresetBaselineId) ?? + defaultBaselineMockPreset.id + ); +}; + +/** + * Saves ID of selected MSW preset in local storage. + */ +export const saveBaselinePreset = (presetId: MockPresetBaselineId): void => { + localStorage.setItem(LOCAL_STORAGE_PRESET_KEY, presetId); +}; + +/** + * Retrieves the seeding count map from local storage. + */ +export const getSeedsCountMap = (): { [key: string]: number } => { + const encodedCountMap = localStorage.getItem( + LOCAL_STORAGE_SEEDS_COUNT_MAP_KEY + ); + + return encodedCountMap ? JSON.parse(encodedCountMap) : {}; +}; + +/** + * Saves the seeding count map to local storage. + */ +export const saveSeedsCountMap = (countMap: { [key: string]: number }) => { + localStorage.setItem( + LOCAL_STORAGE_SEEDS_COUNT_MAP_KEY, + JSON.stringify(countMap) + ); +}; + +/** + * Retrieves the presets map from local storage. + */ +export const getExtraPresetsMap = (): { + [K in MockPresetExtraId]: number; +} => { + const encodedPresetsMap = localStorage.getItem(LOCAL_STORAGE_PRESETS_MAP_KEY); + + return encodedPresetsMap ? JSON.parse(encodedPresetsMap) : {}; +}; + +/** + * Saves the presets map to local storage. + */ +export const saveExtraPresetsMap = (presetsMap: { [key: string]: number }) => { + localStorage.setItem( + LOCAL_STORAGE_PRESETS_MAP_KEY, + JSON.stringify(presetsMap) + ); +}; + +/** + * Returns an array of enabled extra MSW presets that are stored in local storage. + * + * An empty array is returned when the expected data does not exist in local + * storage. + */ +export const getExtraPresets = (): string[] => { + const encodedPresets = localStorage.getItem(LOCAL_STORAGE_PRESET_EXTRAS_KEY); + if (!encodedPresets) { + return []; + } + const storedPresets = encodedPresets.split(','); + + // Filter out any stored presets that no longer exist in the code base. + return storedPresets.filter((storedPreset) => + extraMockPresets.find( + (extraMockPreset) => extraMockPreset.id === storedPreset + ) + ); +}; + +/** + * Saves the extra MSW presets to local storage. + */ +export const saveExtraPresets = (presets: string[]) => { + localStorage.setItem(LOCAL_STORAGE_PRESET_EXTRAS_KEY, presets.join(',')); +}; + +/** + * Returns an array of enabled context seeders that are stored in local storage. + * + * An empty array is returned when the expected data does not exist in local + * storage. + */ +export const getSeeders = (dbSeeders: MockSeeder[]): string[] => { + const encodedPopulators = localStorage.getItem(LOCAL_STORAGE_SEEDERS_KEY); + if (!encodedPopulators) { + return []; + } + const storedSeeders = encodedPopulators.split(','); + + // Filter out any stored populators that no longer exist in the code base. + return storedSeeders.filter((storedSeeder) => + dbSeeders.find((dbSeeder) => dbSeeder.id === storedSeeder) + ); +}; + +/** + * Saves the context seeders to local storage. + */ +export const saveSeeders = (populators: string[]) => { + localStorage.setItem(LOCAL_STORAGE_SEEDERS_KEY, populators.join(',')); +}; diff --git a/packages/manager/src/factories/linodes.ts b/packages/manager/src/factories/linodes.ts index c6bc613b7e4..d7c21a4e5d1 100644 --- a/packages/manager/src/factories/linodes.ts +++ b/packages/manager/src/factories/linodes.ts @@ -117,6 +117,20 @@ export const linodeIPFactory = Factory.Sync.makeFactory({ }, }); +export const linodeBackupFactory = Factory.Sync.makeFactory({ + available: true, + configs: ['My Alpine 3.17 Disk Profile'], + created: '2020-01-01', + disks: [], + finished: '2020-01-01', + id: Factory.each((i) => i), + label: Factory.each((i) => `Backup ${i}`), + region: 'us-east', + status: 'successful', + type: 'auto', + updated: '2020-01-01', +}); + export const linodeBackupsFactory = Factory.Sync.makeFactory({ enabled: true, last_successful: '2020-01-01', diff --git a/packages/manager/src/index.tsx b/packages/manager/src/index.tsx index 729f8054e8a..6696c5dd197 100644 --- a/packages/manager/src/index.tsx +++ b/packages/manager/src/index.tsx @@ -1,8 +1,6 @@ import CssBaseline from '@mui/material/CssBaseline'; import { QueryClientProvider } from '@tanstack/react-query'; -import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import * as React from 'react'; -import { createRoot } from 'react-dom/client'; import { Provider as ReduxStoreProvider } from 'react-redux'; import { Route, BrowserRouter as Router, Switch } from 'react-router-dom'; @@ -16,10 +14,11 @@ import { storeFactory } from 'src/store'; import { App } from './App'; import NullComponent from './components/NullComponent'; -import { loadDevTools, shouldEnableDevTools } from './dev-tools/load'; +import { loadDevTools, shouldLoadDevTools } from './dev-tools/load'; import './index.css'; import { LinodeThemeWrapper } from './LinodeThemeWrapper'; import { queryClientFactory } from './queries/base'; +import { getRoot } from './utilities/rootManager'; const queryClient = queryClientFactory('longLived'); const store = storeFactory(); @@ -80,21 +79,21 @@ const Main = () => { - ); }; async function loadApp() { - if (shouldEnableDevTools) { - // If devtools are enabled, load them before we load the main app. - // This ensures the MSW is setup before we start making API calls. - await loadDevTools(store); + if (shouldLoadDevTools) { + await loadDevTools(store, queryClient); } + const container = document.getElementById('root'); - const root = createRoot(container!); - root.render(
    ); + if (container) { + const root = getRoot(container); + root.render(
    ); + } } loadApp(); diff --git a/packages/manager/src/mocks/indexedDB.ts b/packages/manager/src/mocks/indexedDB.ts new file mode 100644 index 00000000000..6be1d3cdb61 --- /dev/null +++ b/packages/manager/src/mocks/indexedDB.ts @@ -0,0 +1,527 @@ +import { hasId } from './presets/crud/seeds/utils'; + +import type { MockState } from './types'; + +type ObjectStore = 'mockState' | 'seedState'; + +const MOCK_STATE: ObjectStore = 'mockState'; +const SEED_STATE: ObjectStore = 'seedState'; + +export const mswDB = { + add: async ( + entity: T, + payload: MockState[T] extends Array ? U : MockState[T], + state: MockState + ): Promise => { + const db = await mswDB.open('MockDB', 1); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([MOCK_STATE], 'readwrite'); + const store = transaction.objectStore(MOCK_STATE); + const request = store.get(1); + + request.onsuccess = () => { + const mockState = request.result; + + if (!mockState?.[entity]) { + reject(); + + return; + } + + // Generate unique ID if necessary + if (hasId(payload)) { + let newId = payload.id; + + while ( + mockState[entity].some( + // eslint-disable-next-line no-loop-func + (item: { id: number }) => item.id === newId + ) + ) { + newId = newId + 1; + } + payload.id = newId; + } + + mockState[entity].push(payload); + state[entity].push(payload as any); + + const updatedRequest = store.put({ id: 1, ...mockState }); + + updatedRequest.onsuccess = () => { + resolve(); + }; + updatedRequest.onerror = (event) => { + reject(event); + }; + }; + request.onerror = (event) => { + reject(event); + }; + }); + }, + + addMany: async ( + entity: T, + payload: MockState[T] extends Array ? U[] : never, + state?: MockState, + objectStore: ObjectStore = MOCK_STATE + ): Promise => { + const db = await mswDB.open('MockDB', 1); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([objectStore], 'readwrite'); + const store = transaction.objectStore(objectStore); + const request = store.get(1); + + request.onsuccess = () => { + const mockState = request.result; + if (!mockState?.entity) { + reject(); + + return; + } + + // Generate unique ID if necessary + payload.forEach((item) => { + if (!hasId(item)) { + return; + } + let newId = item.id; + + while ( + mockState[entity].some( + // eslint-disable-next-line no-loop-func + (item: { id: number }) => item.id === newId + ) + ) { + newId = newId + 1; + } + item.id = newId; + }); + + mockState[entity].push(...payload); + if (state) { + state[entity].push(...(payload as any)); + } + + const updatedRequest = store.put({ id: 1, ...mockState }); + + updatedRequest.onsuccess = () => { + resolve(); + }; + updatedRequest.onerror = (event) => { + reject(event); + }; + }; + request.onerror = (event) => { + reject(event); + }; + }); + }, + + clear: async (objectStore: ObjectStore = MOCK_STATE): Promise => { + const db = await mswDB.open('MockDB', 1); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([objectStore], 'readwrite'); + const store = transaction.objectStore(objectStore); + const request = store.clear(); + + request.onsuccess = () => { + resolve(); + }; + request.onerror = (event) => { + reject(event); + }; + }); + }, + + delete: async ( + entity: T, + id: number, + state: MockState + ): Promise => { + const db = await mswDB.open('MockDB', 1); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([MOCK_STATE, SEED_STATE], 'readwrite'); + const store = transaction.objectStore(MOCK_STATE); + const seedStore = transaction.objectStore(SEED_STATE); + + const storeRequest = store.get(1); + const seedRequest = seedStore.get(1); + + storeRequest.onsuccess = () => { + const mockState = storeRequest.result; + + seedRequest.onsuccess = () => { + const seedState = seedRequest.result; + + const deleteEntity = (state: MockState | undefined) => { + if (state && state[entity]) { + const index = state[entity].findIndex((item) => { + if (!hasId(item)) { + return false; + } + + return item.id === id; + }); + if (index !== -1) { + state[entity].splice(index, 1); + } + } + }; + + deleteEntity(mockState); + deleteEntity(seedState); + + if (state[entity]) { + const stateIndex = state[entity].findIndex((item) => { + if (!hasId(item)) { + return false; + } + + return item.id === id; + }); + if (stateIndex !== -1) { + state[entity].splice(stateIndex, 1); + } + } + + const updateStoreRequest = store.put({ id: 1, ...mockState }); + const updateSeedRequest = seedStore.put({ id: 1, ...seedState }); + + Promise.all([ + new Promise((resolve, reject) => { + updateStoreRequest.onsuccess = () => resolve(); + updateStoreRequest.onerror = (event) => reject(event); + }), + new Promise((resolve, reject) => { + updateSeedRequest.onsuccess = () => resolve(); + updateSeedRequest.onerror = (event) => reject(event); + }), + ]) + .then(() => resolve()) + .catch((error) => reject(error)); + }; + + seedRequest.onerror = (event) => reject(event); + }; + + storeRequest.onerror = (event) => reject(event); + }); + }, + + deleteAll: async ( + entity: T, + state: MockState, + objectStore: ObjectStore + ): Promise => { + const db = await mswDB.open('MockDB', 1); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([objectStore], 'readwrite'); + const store = transaction.objectStore(objectStore); + const request = store.get(1); + + request.onsuccess = () => { + const mockState = request.result; + if (!mockState) { + reject(); + return; + } + + if (!mockState[entity]) { + reject(); + return; + } + + mockState[entity] = []; + if (state?.[entity]) { + state[entity] = []; + } + + const updatedRequest = store.put({ id: 1, ...mockState }); + + updatedRequest.onsuccess = () => { + resolve(); + }; + updatedRequest.onerror = (event) => { + reject(event); + }; + }; + request.onerror = (event) => { + reject(event); + }; + }); + }, + + deleteMany: async ( + entity: T, + ids: number[], + state?: MockState, + objectStore: ObjectStore = MOCK_STATE + ): Promise => { + const db = await mswDB.open('MockDB', 1); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([objectStore], 'readwrite'); + const store = transaction.objectStore(objectStore); + const request = store.get(1); + + request.onsuccess = () => { + const mockState = request.result; + if (!mockState?.[entity]) { + reject(new Error('Entity not found')); + return; + } + + ids.forEach((id) => { + const index = mockState[entity].findIndex( + (item: Record) => item.id === id + ); + + mockState[entity].splice(index, 1); + if (state) { + state[entity].splice(index, 1); + } + }); + + const updatedRequest = store.put({ id: 1, ...mockState }); + + updatedRequest.onsuccess = () => { + resolve(); + }; + updatedRequest.onerror = (event) => { + reject(event); + }; + }; + request.onerror = (event) => { + reject(event); + }; + }); + }, + + get: async ( + entity: T, + id: number + ): Promise< + (MockState[T] extends Array ? U : MockState[T]) | undefined + > => { + const db = await mswDB.open('MockDB', 1); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([MOCK_STATE, SEED_STATE], 'readonly'); + const store = transaction.objectStore(MOCK_STATE); + const seedStore = transaction.objectStore(SEED_STATE); + + const storeRequest = store.get(1); + const seedRequest = seedStore.get(1); + + storeRequest.onsuccess = () => { + const mockState = storeRequest.result; + seedRequest.onsuccess = () => { + const seedState = seedRequest.result; + + const findEntity = (state: MockState | undefined) => { + return state?.[entity]?.find((item) => { + if (!hasId(item)) { + return false; + } + return item.id === id; + }); + }; + + const mockEntity = findEntity(mockState); + const seedEntity = findEntity(seedState); + + resolve( + (mockEntity ?? seedEntity) as MockState[T] extends Array + ? U + : MockState[T] + ); + }; + seedRequest.onerror = (event) => { + reject(event); + }; + }; + storeRequest.onerror = (event) => { + reject(event); + }; + }); + }, + + getAll: async ( + entity: T + ): Promise => { + const db = await mswDB.open('MockDB', 1); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([MOCK_STATE, SEED_STATE], 'readonly'); + const store = transaction.objectStore(MOCK_STATE); + const seedStore = transaction.objectStore(SEED_STATE); + + const storeRequest = store.get(1); + const seedRequest = seedStore.get(1); + + storeRequest.onsuccess = () => { + const mockState = storeRequest.result; + seedRequest.onsuccess = () => { + const seedState = seedRequest.result; + const mockEntities = mockState?.[entity] || []; + const seedEntities = seedState?.[entity] || []; + + resolve([...mockEntities, ...seedEntities] as MockState[T]); + }; + seedRequest.onerror = (event) => { + reject(event); + }; + }; + storeRequest.onerror = (event) => { + reject(event); + }; + }); + }, + + /** + * Retrieves the whole mock state from IndexedDB. + */ + getStore: async ( + objectStore: ObjectStore = MOCK_STATE + ): Promise => { + const db = await mswDB.open('MockDB', 1); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([objectStore], 'readonly'); + const store = transaction.objectStore(objectStore); + const request = store.get(1); + + request.onsuccess = () => { + resolve(request.result); + }; + request.onerror = (event) => { + reject(event); + }; + }); + }, + + /** + * Opens the IndexedDB with the given name and version. + * Create the object store if it doesn't exist. + */ + open: (name: string, version: number): Promise => { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, version); + + request.onerror = (event) => { + reject(event); + }; + request.onsuccess = () => { + resolve(request.result); + }; + request.onupgradeneeded = () => { + const db = request.result; + + if (!db.objectStoreNames.contains(MOCK_STATE)) { + db.createObjectStore(MOCK_STATE, { keyPath: 'id' }); + } + + if (!db.objectStoreNames.contains(SEED_STATE)) { + db.createObjectStore(SEED_STATE, { keyPath: 'id' }); + } + }; + }); + }, + + /** + * Saves the given mock state to IndexedDB. + * Useful to replace or initialize the whole mock state. + */ + saveStore: async ( + data: MockState, + objectStore: ObjectStore = MOCK_STATE + ): Promise => { + const db = await mswDB.open('MockDB', 1); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([objectStore], 'readwrite'); + const store = transaction.objectStore(objectStore); + // eslint-disable-next-line xss/no-mixed-html + const sanitizedData = JSON.parse(JSON.stringify(data)); + // eslint-disable-next-line xss/no-mixed-html + const request = store.put({ id: 1, ...sanitizedData }); + + request.onsuccess = () => { + resolve(); + }; + request.onerror = (event) => { + reject(event); + }; + }); + }, + + update: async ( + entity: T, + id: number, + payload: Partial ? U : MockState[T]>, + state: MockState + ): Promise => { + const db = await mswDB.open('MockDB', 1); + + return new Promise((resolve, reject) => { + const transaction = db.transaction([MOCK_STATE, SEED_STATE], 'readwrite'); + const store = transaction.objectStore(MOCK_STATE); + const seedStore = transaction.objectStore(SEED_STATE); + + const storeRequest = store.get(1); + const seedRequest = seedStore.get(1); + + storeRequest.onsuccess = () => { + const mockState = storeRequest.result; + if (mockState && mockState[entity]) { + const index = mockState[entity].findIndex( + (item: { id: number }) => item.id === id + ); + if (index !== -1) { + Object.assign(mockState[entity][index], payload); + Object.assign(state[entity][index], payload); + + const updatedRequest = store.put({ id: 1, ...mockState }); + updatedRequest.onsuccess = () => resolve(); + updatedRequest.onerror = (event) => reject(event); + return; + } + } + + seedRequest.onsuccess = () => { + const seedState = seedRequest.result; + if (!seedState || !seedState[entity]) { + reject(new Error('Entity not found')); + return; + } + + const index = seedState[entity].findIndex( + (item: { id: number }) => item.id === id + ); + if (index === -1) { + reject(new Error('Item not found')); + return; + } + + Object.assign(seedState[entity][index], payload); + Object.assign(state[entity][index], payload); + + const updatedSeedRequest = seedStore.put({ id: 1, ...seedState }); + updatedSeedRequest.onsuccess = () => resolve(); + updatedSeedRequest.onerror = (event) => reject(event); + }; + + seedRequest.onerror = (event) => reject(event); + }; + + storeRequest.onerror = (event) => reject(event); + }); + }, +}; diff --git a/packages/manager/src/mocks/mockPreset.ts b/packages/manager/src/mocks/mockPreset.ts new file mode 100644 index 00000000000..482e348e332 --- /dev/null +++ b/packages/manager/src/mocks/mockPreset.ts @@ -0,0 +1,38 @@ +import type { + MockHandler, + MockPresetBaseline, + MockPresetCrud, + MockPresetExtra, + MockState, +} from './types'; +import type { HttpHandler } from 'msw'; + +/** + * Executes a preset's handler generators and returns the resulting handlers. + * + * @param preset - Mock preset to resolve. + * + * @returns Array of HTTP handlers generated for the mock preset. + */ +export const resolveMockPreset = ( + preset: MockPresetBaseline | MockPresetCrud | MockPresetExtra, + state: MockState +): HttpHandler[] => { + return preset.handlers.reduce((acc: HttpHandler[], cur: MockHandler) => { + return [...cur(state), ...acc]; + }, []); +}; + +/** + * Describes a collection of HTTP handlers that collectively form a MSW preset. + * */ +export const getMockPresetGroups = ( + presets: (MockPresetBaseline | MockPresetCrud | MockPresetExtra)[] +): string[] => { + return presets.reduce((acc: string[], cur) => { + if (!acc.includes(cur.group.id)) { + acc.push(cur.group.id); + } + return acc; + }, []); +}; diff --git a/packages/manager/src/mocks/mockState.ts b/packages/manager/src/mocks/mockState.ts new file mode 100644 index 00000000000..429794b7365 --- /dev/null +++ b/packages/manager/src/mocks/mockState.ts @@ -0,0 +1,49 @@ +import { mswDB } from './indexedDB'; + +import type { MockSeeder, MockState } from './types'; + +/** + * Describes a function that executes on each request to the events endpoint. + * + * Can be used to simulate progress or update state in response to an event. + * + * @returns `true` if event is considered complete, `false` if callback should continue to be called. + */ +export const getStateSeederGroups = ( + seeders: MockSeeder[] +): Array => { + return seeders.reduce((acc: Array, cur) => { + if (!acc.includes(cur.group.id)) { + acc.push(cur.group.id); + } + + return acc; + }, []); +}; + +export const emptyStore: MockState = { + eventQueue: [], + firewalls: [], + linodeConfigs: [], + linodes: [], + notificationQueue: [], + placementGroups: [], + regionAvailability: [], + regions: [], + volumes: [], +}; + +/** + * Creates and returns an empty mock state. + * + * @returns Empty mock state. + */ +export const createInitialMockStore = async (): Promise => { + const mockState = await mswDB.getStore('mockState'); + + if (mockState) { + return mockState; + } + + return emptyStore; +}; diff --git a/packages/manager/src/mocks/mswWorkers.ts b/packages/manager/src/mocks/mswWorkers.ts new file mode 100644 index 00000000000..1c79843ff46 --- /dev/null +++ b/packages/manager/src/mocks/mswWorkers.ts @@ -0,0 +1,14 @@ +import { setupWorker } from 'msw/browser'; + +import { handlers } from './serverHandlers'; + +import type { HttpHandler } from 'msw'; + +export const worker = ( + extraHandlers: HttpHandler[], + baseHandlers: HttpHandler[] +) => { + return setupWorker(...extraHandlers, ...baseHandlers); +}; + +export const storybookWorker = setupWorker(...handlers); diff --git a/packages/manager/src/mocks/presets/baseline/accountReview.ts b/packages/manager/src/mocks/presets/baseline/accountReview.ts new file mode 100644 index 00000000000..3161969b429 --- /dev/null +++ b/packages/manager/src/mocks/presets/baseline/accountReview.ts @@ -0,0 +1,23 @@ +import { http } from 'msw'; + +import { makeErrorResponse } from 'src/mocks/utilities/response'; + +import type { MockPresetBaseline } from 'src/mocks/types'; + +const respondWithAccountActivation = () => { + return [ + // @TODO Add handlers for support-related endpoints that are still accessible to accounts under review. + http.all('*/v4*/*', () => { + const errorMessage = + 'Your account must be activated before you can use this endpoint'; + return makeErrorResponse(errorMessage, 403); + }), + ]; +}; + +export const baselineAccountActivationPreset: MockPresetBaseline = { + group: { id: 'Account State' }, + handlers: [respondWithAccountActivation], + id: 'baseline:account-activation', + label: 'Account Activation Required', +}; diff --git a/packages/manager/src/mocks/presets/baseline/apiMaintenanceMode.ts b/packages/manager/src/mocks/presets/baseline/apiMaintenanceMode.ts new file mode 100644 index 00000000000..40716a1de30 --- /dev/null +++ b/packages/manager/src/mocks/presets/baseline/apiMaintenanceMode.ts @@ -0,0 +1,26 @@ +import { http } from 'msw'; + +import { makeErrorResponse } from 'src/mocks/utilities/response'; + +import type { MockPresetBaseline } from 'src/mocks/types'; + +/** + * Mock all requests to Linode API v4 to respond with maintenance mode status and header. + */ +const respondWithMaintenanceMode = () => { + return [ + // Match against all APIv4 and APIv4 Beta API requests. + http.all('*/v4*/*', () => { + return makeErrorResponse('Currently in maintenance mode.', 503, { + 'x-maintenance-mode': 'all,All endpoints are temporarily unavailable.', + }); + }), + ]; +}; + +export const baselineApiMaintenanceModePreset: MockPresetBaseline = { + group: { id: 'API State' }, + handlers: [respondWithMaintenanceMode], + id: 'baseline:api-maintenance', + label: 'API Maintenance Mode', +}; diff --git a/packages/manager/src/mocks/presets/baseline/apiOffline.ts b/packages/manager/src/mocks/presets/baseline/apiOffline.ts new file mode 100644 index 00000000000..04e098f24bd --- /dev/null +++ b/packages/manager/src/mocks/presets/baseline/apiOffline.ts @@ -0,0 +1,21 @@ +import { HttpResponse, http } from 'msw'; + +import type { MockPresetBaseline } from 'src/mocks/types'; + +/** + * Mock all requests to Linode API v4 to mock HTTP request failure. + */ +const respondWithFailure = () => { + return [ + http.all('*/v4*/*', () => { + return HttpResponse.error(); + }), + ]; +}; + +export const baselineApiOfflinePreset: MockPresetBaseline = { + group: { id: 'API State' }, + handlers: [respondWithFailure], + id: 'baseline:api-offline', + label: 'API Offline', +}; diff --git a/packages/manager/src/mocks/presets/baseline/apiUnstable.ts b/packages/manager/src/mocks/presets/baseline/apiUnstable.ts new file mode 100644 index 00000000000..e5b9033df10 --- /dev/null +++ b/packages/manager/src/mocks/presets/baseline/apiUnstable.ts @@ -0,0 +1,35 @@ +import { http, passthrough } from 'msw'; + +import { makeErrorResponse } from 'src/mocks/utilities/response'; + +import type { MockPresetBaseline } from 'src/mocks/types'; + +/** + * The rate at which API responses will randomly fail. + */ +const FAILURE_RATE = 0.75; + +/** + * Mock all requests to Linode API v4 to mock HTTP request failure. + */ +const respondWithFailure = () => { + return [ + http.all('*/v4*/*', () => { + const shouldFail = Math.random() <= FAILURE_RATE; + const statusCode = Math.random() <= 0.5 ? 500 : 502; + + if (shouldFail) { + return makeErrorResponse('An unknown error occurred.', statusCode); + } + + return passthrough(); + }), + ]; +}; + +export const baselineApiUnstablePreset: MockPresetBaseline = { + group: { id: 'API State' }, + handlers: [respondWithFailure], + id: 'baseline:api-unstable', + label: 'API Unstable', +}; diff --git a/packages/manager/src/mocks/presets/baseline/crud.ts b/packages/manager/src/mocks/presets/baseline/crud.ts new file mode 100644 index 00000000000..3fc0fd6db79 --- /dev/null +++ b/packages/manager/src/mocks/presets/baseline/crud.ts @@ -0,0 +1,25 @@ +import { + getEvents, + updateEvents, +} from 'src/mocks/presets/crud/handlers/events'; +import { linodeCrudPreset } from 'src/mocks/presets/crud/linodes'; + +import { placementGroupsCrudPreset } from '../crud/placementGroups'; +import { volumeCrudPreset } from '../crud/volumes'; + +import type { MockPresetBaseline } from 'src/mocks/types'; + +export const baselineCrudPreset: MockPresetBaseline = { + group: { id: 'General' }, + handlers: [ + ...linodeCrudPreset.handlers, + ...placementGroupsCrudPreset.handlers, + ...volumeCrudPreset.handlers, + + // Events. + getEvents, + updateEvents, + ], + id: 'baseline:crud', + label: 'CRUD', +}; diff --git a/packages/manager/src/mocks/presets/baseline/legacy.ts b/packages/manager/src/mocks/presets/baseline/legacy.ts new file mode 100644 index 00000000000..24b1e7358b9 --- /dev/null +++ b/packages/manager/src/mocks/presets/baseline/legacy.ts @@ -0,0 +1,17 @@ +/** + * @file MSW preset that uses our legacy handlers, for backwards compatibility. + */ + +import { handlers } from '../../serverHandlers'; + +import type { MockPresetBaseline } from 'src/mocks/types'; + +/** + * Baseline mock preset that uses our legacy MSW handlers. + */ +export const baselineLegacyPreset: MockPresetBaseline = { + group: { id: 'General' }, + handlers: [() => handlers], + id: 'baseline:legacy', + label: 'Legacy MSW Handlers', +}; diff --git a/packages/manager/src/mocks/presets/baseline/noMocks.ts b/packages/manager/src/mocks/presets/baseline/noMocks.ts new file mode 100644 index 00000000000..3fdef763c0d --- /dev/null +++ b/packages/manager/src/mocks/presets/baseline/noMocks.ts @@ -0,0 +1,16 @@ +/** + * @file No mock MSW preset. + */ +import type { MockPresetBaseline } from 'src/mocks/types'; + +/** + * Baseline mock preset that does not mock any HTTP requests. + * + * Useful in cases where only specific functionality needs to be mocked. + */ +export const baselineNoMocksPreset: MockPresetBaseline = { + group: { id: 'General' }, + handlers: [], + id: 'baseline:preset-mocking', + label: 'Preset Mocking', +}; diff --git a/packages/manager/src/mocks/presets/crud/handlers/events.ts b/packages/manager/src/mocks/presets/crud/handlers/events.ts new file mode 100644 index 00000000000..a2db470d5ac --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/handlers/events.ts @@ -0,0 +1,131 @@ +import { DateTime } from 'luxon'; +import { http } from 'msw'; + +import { mswDB } from '../../../indexedDB'; +import { + makeNotFoundResponse, + makePaginatedResponse, + makeResponse, +} from '../../../utilities/response'; + +import type { + APIErrorResponse, + APIPaginatedResponse, +} from '../../../utilities/response'; +import type { Event } from '@linode/api-v4'; +import type { StrictResponse } from 'msw'; +import type { MockState } from 'src/mocks/types'; + +/** + * Filters events by their `created` date. + * + * Events with `created` dates in the future are filtered out. + * + * @param event - Event to filter. + * + * @returns `true` if event's created date is in the past, `false` otherwise. + */ +const filterEventsByCreatedTime = (eventQueueItem: Event): boolean => { + if (!eventQueueItem) { + return false; + } + + return DateTime.fromISO(eventQueueItem.created) <= DateTime.now(); +}; + +export const getEvents = () => [ + http.get( + '*/v4*/events', + async ({ + request, + }): Promise< + StrictResponse> + > => { + const eventQueue = await mswDB.getAll('eventQueue'); + + if (!eventQueue) { + return makeNotFoundResponse(); + } + const events = eventQueue.filter(filterEventsByCreatedTime); + + return makePaginatedResponse({ + data: events, + request, + }); + } + ), + + http.get( + '*/v4*/events/:id', + async ({ params }): Promise> => { + const id = Number(params.id); + const event = await mswDB.get('eventQueue', id); + + if (!event) { + return makeNotFoundResponse(); + } + + return makeResponse(event); + } + ), +]; + +export const updateEvents = (mockState: MockState) => [ + http.post('*/v4*/events/:id/seen', async ({ params }) => { + const id = Number(params.id); + const eventQueue = await mswDB.getAll('eventQueue'); + + if (!eventQueue) { + return makeNotFoundResponse(); + } + + eventQueue.forEach(async (eventQueueItem) => { + if (eventQueueItem.id <= id) { + const updatedEvent = { + ...eventQueueItem, + seen: true, + }; + + await mswDB.update( + 'eventQueue', + eventQueueItem.id, + updatedEvent, + mockState + ); + } + }); + + // API-v4 responds with a 200 and empty object regardless of whether the + // requested event actually exists (or belongs to the requesting user). + return makeResponse({}); + }), + // Marks all events up to and including the event with the given ID as read. + http.post('*/v4*/events/:id/read', async ({ params }) => { + const id = Number(params.id); + const eventQueue = await mswDB.getAll('eventQueue'); + + if (!eventQueue) { + return makeNotFoundResponse(); + } + + eventQueue.forEach(async (eventQueueItem) => { + if (eventQueueItem.id <= id) { + const updatedEvent = { + ...eventQueueItem, + read: true, + }; + + await mswDB.update( + 'eventQueue', + eventQueueItem.id, + updatedEvent as any, + mockState + ); + } + }); + + // API-v4 responds with a 200 and empty object regardless of whether the + // requested event actually exists (or belongs to the requesting user). + return makeResponse({}); + }), +]; diff --git a/packages/manager/src/mocks/presets/crud/handlers/firewalls.ts b/packages/manager/src/mocks/presets/crud/handlers/firewalls.ts new file mode 100644 index 00000000000..d537263ae76 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/handlers/firewalls.ts @@ -0,0 +1,28 @@ +import { http } from 'msw'; + +import { makePaginatedResponse } from 'src/mocks/utilities/response'; + +import type { Firewall } from '@linode/api-v4'; +import type { StrictResponse } from 'msw'; +import type { MockState } from 'src/mocks/types'; +import type { + APIErrorResponse, + APIPaginatedResponse, +} from 'src/mocks/utilities/response'; + +// TODO add CRUD handlers +export const getFirewalls = (mockState: MockState) => [ + http.get( + '*/v4beta/networking/firewalls', + ({ + request, + }): StrictResponse> => { + return makePaginatedResponse({ + data: mockState.firewalls, + request, + }); + } + ), +]; + +// TODO add create, update, delete handlers diff --git a/packages/manager/src/mocks/presets/crud/handlers/linodes.ts b/packages/manager/src/mocks/presets/crud/handlers/linodes.ts new file mode 100644 index 00000000000..2ffffa60f3d --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/handlers/linodes.ts @@ -0,0 +1,453 @@ +import { DateTime } from 'luxon'; +import { http } from 'msw'; + +import { + configFactory, + linodeBackupFactory, + linodeDiskFactory, + linodeFactory, + linodeIPFactory, + linodeStatsFactory, + linodeTransferFactory, +} from 'src/factories'; +import { queueEvents } from 'src/mocks/utilities/events'; +import { + makeNotFoundResponse, + makePaginatedResponse, + makeResponse, +} from 'src/mocks/utilities/response'; + +import { mswDB } from '../../../indexedDB'; + +import type { + Config, + Disk, + Firewall, + Linode, + LinodeBackupsResponse, + LinodeIPsResponse, + RegionalNetworkUtilization, + Stats, +} from '@linode/api-v4'; +import type { StrictResponse } from 'msw'; +import type { MockState } from 'src/mocks/types'; +import type { + APIErrorResponse, + APIPaginatedResponse, +} from 'src/mocks/utilities/response'; + +export const getLinodes = () => [ + http.get( + '*/v4/linode/instances', + async ({ + request, + }): Promise< + StrictResponse> + > => { + const linodes = await mswDB.getAll('linodes'); + + if (!linodes) { + return makeNotFoundResponse(); + } + + return makePaginatedResponse({ + data: linodes, + request, + }); + } + ), + + http.get( + '*/v4/linode/instances/:id', + async ({ params }): Promise> => { + const id = Number(params.id); + const linode = await mswDB.get('linodes', id); + + if (!linode) { + return makeNotFoundResponse(); + } + + return makeResponse(linode); + } + ), + + http.get( + '*/v4/linode/instances/:id/configs', + async ({ + params, + request, + }): Promise< + StrictResponse> + > => { + const id = Number(params.id); + const linode = await mswDB.get('linodes', id); + const linodeConfigs = await mswDB.getAll('linodeConfigs'); + + if (!linode || !linodeConfigs) { + return makeNotFoundResponse(); + } + + const configs = linodeConfigs + .filter((configTuple) => configTuple[0] === id) + .map((configTuple) => configTuple[1]); + + return makePaginatedResponse({ + data: configs, + request, + }); + } + ), +]; + +export const createLinode = (mockState: MockState) => [ + http.post('*/v4/linode/instances', async ({ request }) => { + const payload = await request.clone().json(); + const linode = linodeFactory.build({ + created: DateTime.now().toISO(), + status: 'provisioning', + ...payload, + }); + + if (!linode.label) { + linode.label = `linode${linode.id}`; + } + + const linodeConfig = configFactory.build({ + created: DateTime.now().toISO(), + }); + + await mswDB.add('linodes', linode, mockState); + await mswDB.add('linodeConfigs', [linode.id, linodeConfig], mockState); + + queueEvents({ + event: { + action: 'linode_create', + entity: { + id: linode.id, + label: linode.label, + type: 'linode', + url: `/v4/linode/instances/${linode.id}`, + }, + }, + mockState, + sequence: [ + { status: 'scheduled' }, + { isProgressEvent: true, status: 'started' }, + { status: 'finished' }, + ], + }) + .then(async () => { + await mswDB.update( + 'linodes', + linode.id, + { status: 'booting' }, + mockState + ); + + return queueEvents({ + event: { + action: 'linode_boot', + entity: { + id: linode.id, + label: linode.label, + type: 'linode', + url: `/v4/linode/instances/${linode.id}`, + }, + }, + mockState, + sequence: [ + { isProgressEvent: true, status: 'started' }, + { status: 'finished' }, + ], + }); + }) + .then(async () => { + await mswDB.update( + 'linodes', + linode.id, + { status: 'running' }, + mockState + ); + }); + + return makeResponse(linode); + }), +]; + +export const updateLinode = (mockState: MockState) => [ + http.put( + '*/v4/linode/instances/:id', + async ({ + params, + request, + }): Promise> => { + const id = Number(params.id); + const payload = await request.clone().json(); + const linode = await mswDB.get('linodes', id); + + if (!linode) { + return makeNotFoundResponse(); + } + + await mswDB.update('linodes', id, payload, mockState); + + queueEvents({ + event: { + action: 'linode_update', + entity: { + id: linode.id, + label: linode.label, + type: 'linode', + url: `/v4/linode/instances/${linode.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse(linode); + } + ), +]; + +export const deleteLinode = (mockState: MockState) => [ + http.delete('*/v4/linode/instances/:id', async ({ params }) => { + const id = Number(params.id); + const linode = await mswDB.get('linodes', id); + + if (!linode) { + return makeNotFoundResponse(); + } + + queueEvents({ + event: { + action: 'linode_shutdown', + entity: { + id: linode.id, + label: linode.label, + type: 'linode', + url: `/v4/linode/instances/${linode.id}`, + }, + }, + mockState, + sequence: [ + { status: 'scheduled' }, + { isProgressEvent: true, status: 'started' }, + { status: 'finished' }, + ], + }).then(async () => { + await mswDB.update('linodes', id, { status: 'shutting_down' }, mockState); + + return queueEvents({ + event: { + action: 'linode_delete', + entity: { + id: linode.id, + label: linode.label, + type: 'linode', + url: `/v4/linode/instances/${linode.id}`, + }, + }, + mockState, + sequence: [{ status: 'finished' }], + }).then(async () => { + await mswDB.delete('linodes', id, mockState); + }); + }); + + return makeResponse({}); + }), +]; + +// Intentionally not storing static data the DB +export const getLinodeStats = (mockState: MockState) => [ + http.get( + '*/v4/linode/instances/:id/stats*', + ({ params }): StrictResponse => { + const id = Number(params.id); + const linode = mockState.linodes.find( + (stateLinode) => stateLinode.id === id + ); + + if (!linode) { + return makeNotFoundResponse(); + } + + const mockStats = linodeStatsFactory.build(); + + return makeResponse(mockStats); + } + ), +]; + +// TODO: integrate with DB +export const getLinodeDisks = (mockState: MockState) => [ + http.get( + '*/v4/linode/instances/:id/disks', + ({ + params, + request, + }): StrictResponse> => { + const id = Number(params.id); + const linode = mockState.linodes.find( + (stateLinode) => stateLinode.id === id + ); + + if (!linode) { + return makeNotFoundResponse(); + } + + const mockDisks = linodeDiskFactory.buildList(3); + + return makePaginatedResponse({ + data: mockDisks, + request, + }); + } + ), +]; + +// TODO: integrate with DB +export const getLinodeTransfer = (mockState: MockState) => [ + http.get( + '*/v4/linode/instances/:id/transfer', + ({ + params, + }): StrictResponse => { + const id = Number(params.id); + const linode = mockState.linodes.find( + (stateLinode) => stateLinode.id === id + ); + + if (!linode) { + return makeNotFoundResponse(); + } + + const mockTransfer = linodeTransferFactory.build(); + + return makeResponse(mockTransfer); + } + ), +]; + +export const getLinodeFirewalls = (mockState: MockState) => [ + http.get( + '*/v4/linode/instances/:id/firewalls', + async ({ + params, + request, + }): Promise< + StrictResponse> + > => { + const id = Number(params.id); + const linode = mockState.linodes.find( + (stateLinode) => stateLinode.id === id + ); + const allFirewalls = await mswDB.getAll('firewalls'); + + if (!linode || !allFirewalls) { + return makeNotFoundResponse(); + } + + const linodeFirewalls = allFirewalls.filter((firewall) => + firewall.entities.some((entity) => entity.id === id) + ); + + return makePaginatedResponse({ + data: linodeFirewalls, + request, + }); + } + ), +]; + +// TODO: integrate with DB +export const getLinodeIps = (mockState: MockState) => [ + http.get( + '*/v4/linode/instances/:id/ips', + ({ params }): StrictResponse => { + const id = Number(params.id); + const linode = mockState.linodes.find( + (stateLinode) => stateLinode.id === id + ); + + if (!linode) { + return makeNotFoundResponse(); + } + + const mockLinodeIps = linodeIPFactory.build(); + + return makeResponse(mockLinodeIps); + } + ), +]; + +// TODO: integrate with DB +export const getLinodeBackups = (mockState: MockState) => [ + http.get( + '*/v4/linode/instances/:id/backups', + ({ params }): StrictResponse => { + const id = Number(params.id); + const linode = mockState.linodes.find( + (stateLinode) => stateLinode.id === id + ); + + if (!linode) { + return makeNotFoundResponse(); + } + + const mockLinodeBackup = linodeBackupFactory.build(); + + return makeResponse({ + automatic: [mockLinodeBackup], + snapshot: { + current: null, + in_progress: null, + }, + }); + } + ), +]; + +export const shutDownLinode = (mockState: MockState) => [ + http.post( + '*/v4/linode/instances/:id/shutdown', + async ({ params }): Promise> => { + const id = Number(params.id); + const linode = await mswDB.get('linodes', id); + + if (!linode) { + return makeNotFoundResponse(); + } + + const updatedLinode: Linode = { + ...linode, + status: 'offline', + }; + + queueEvents({ + event: { + action: 'linode_shutdown', + entity: { + id: linode.id, + label: linode.label, + type: 'linode', + url: `/v4/linode/instances/${linode.id}`, + }, + }, + mockState, + sequence: [ + { status: 'scheduled' }, + { isProgressEvent: true, status: 'started' }, + { status: 'finished' }, + ], + }).then(async () => { + await mswDB.update('linodes', id, updatedLinode, mockState); + }); + + return makeResponse(updatedLinode); + } + ), +]; + +// TODO: ad more handlers (reboot, clone, resize, rebuild, rescue, migrate...) as needed diff --git a/packages/manager/src/mocks/presets/crud/handlers/placementGroups.ts b/packages/manager/src/mocks/presets/crud/handlers/placementGroups.ts new file mode 100644 index 00000000000..0095fe2cd63 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/handlers/placementGroups.ts @@ -0,0 +1,307 @@ +import { http } from 'msw'; + +import { placementGroupFactory } from 'src/factories'; +import { mswDB } from 'src/mocks/indexedDB'; +import { + makeNotFoundResponse, + makePaginatedResponse, + makeResponse, +} from 'src/mocks/utilities/response'; + +import { queueEvents } from '../../../utilities/events'; + +import type { + AssignLinodesToPlacementGroupPayload, + CreatePlacementGroupPayload, + PlacementGroup, + UnassignLinodesFromPlacementGroupPayload, + UpdatePlacementGroupPayload, +} from '@linode/api-v4'; +import type { StrictResponse } from 'msw'; +import type { MockState } from 'src/mocks/types'; +import type { + APIErrorResponse, + APIPaginatedResponse, +} from 'src/mocks/utilities/response'; + +export const getPlacementGroups = () => [ + http.get( + '*/v4/placement/groups', + async ({ + request, + }): Promise< + StrictResponse> + > => { + const placementGroups = await mswDB.getAll('placementGroups'); + + if (!placementGroups) { + return makeNotFoundResponse(); + } + + return makePaginatedResponse({ + data: placementGroups, + request, + }); + } + ), + + http.get( + '*/v4/placement/groups/:id', + async ({ + params, + }): Promise> => { + const id = Number(params.id); + const placementGroup = await mswDB.get('placementGroups', id); + + if (!placementGroup) { + return makeNotFoundResponse(); + } + + return makeResponse(placementGroup); + } + ), +]; + +export const createPlacementGroup = (mockState: MockState) => [ + http.post( + '*/v4/placement/groups', + async ({ + request, + }): Promise> => { + const payload: CreatePlacementGroupPayload = await request.clone().json(); + const placementGroup = placementGroupFactory.build({ + ...payload, + is_compliant: true, + members: [], + }); + + await mswDB.add('placementGroups', placementGroup, mockState); + + queueEvents({ + event: { + action: 'placement_group_create', + entity: { + id: placementGroup.id, + label: placementGroup.label, + type: 'placement_group', + url: `/v4/placement/groups/${placementGroup.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse(placementGroup); + } + ), +]; + +export const updatePlacementGroup = (mockState: MockState) => [ + http.put( + '*/v4/placement/groups/:id', + async ({ + params, + request, + }): Promise> => { + const id = Number(params.id); + const payload: UpdatePlacementGroupPayload = await request.clone().json(); + const placementGroup = await mswDB.get('placementGroups', id); + + if (!placementGroup) { + return makeNotFoundResponse(); + } + + mswDB.update('placementGroups', id, payload, mockState); + + queueEvents({ + event: { + action: 'placement_group_update', + entity: { + id: placementGroup.id, + label: placementGroup.label, + type: 'placement_group', + url: `/v4/placement/groups/${placementGroup.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse(placementGroup); + } + ), +]; + +export const deletePlacementGroup = (mockState: MockState) => [ + http.delete('*/v4/placement/groups/:id', async ({ params }) => { + const id = Number(params.id); + const placementGroup = await mswDB.get('placementGroups', id); + + if (!placementGroup) { + return makeNotFoundResponse(); + } + + await mswDB.delete('placementGroups', id, mockState); + + queueEvents({ + event: { + action: 'placement_group_delete', + entity: { + id: placementGroup.id, + label: placementGroup.label, + type: 'placement_group', + url: `/v4/placement/groups/${placementGroup.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse({}); + }), +]; + +export const placementGroupLinodeAssignment = (mockState: MockState) => [ + http.post( + '*/v4/placement/groups/:id/assign', + async ({ + params, + request, + }): Promise> => { + const id = Number(params.id); + const payload: AssignLinodesToPlacementGroupPayload = await request + .clone() + .json(); + const placementGroup = await mswDB.get('placementGroups', id); + const linodeAssigned = await mswDB.get('linodes', payload['linodes'][0]); + + if (!placementGroup || !linodeAssigned) { + return makeNotFoundResponse(); + } + + Object.assign(placementGroup, { + members: [ + ...placementGroup.members, + { + linode_id: payload['linodes'][0], + }, + ], + }); + + await mswDB.update( + 'placementGroups', + placementGroup.id, + { + members: placementGroup.members, + }, + mockState + ); + + await mswDB.update( + 'linodes', + linodeAssigned.id, + { + placement_group: { + id: placementGroup.id, + label: placementGroup.label, + placement_group_policy: placementGroup.placement_group_policy, + placement_group_type: placementGroup.placement_group_type, + }, + }, + mockState + ); + + Object.assign(linodeAssigned, { + placement_group: { + id: placementGroup.id, + label: placementGroup.label, + placement_group_policy: placementGroup.placement_group_policy, + placement_group_type: placementGroup.placement_group_type, + }, + }); + + queueEvents({ + event: { + action: 'placement_group_assign', + entity: { + id: placementGroup.id, + label: placementGroup.label, + type: 'placement_group', + url: `/v4/placement/groups/${placementGroup.id}`, + }, + secondary_entity: { + id: linodeAssigned.id, + label: linodeAssigned.label, + type: 'linode', + url: `/v4/linode/instances/${linodeAssigned.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse(placementGroup); + } + ), + + http.post( + '*/v4/placement/groups/:id/unassign', + async ({ + params, + request, + }): Promise> => { + const id = Number(params.id); + const payload: UnassignLinodesFromPlacementGroupPayload = await request + .clone() + .json(); + const placementGroup = await mswDB.get('placementGroups', id); + const linodeAssigned = await mswDB.get('linodes', payload['linodes'][0]); + + if (!placementGroup || !linodeAssigned) { + return makeNotFoundResponse(); + } + + mswDB.update( + 'placementGroups', + placementGroup.id, + { + members: placementGroup.members.filter( + (member) => member.linode_id !== payload['linodes'][0] + ), + }, + mockState + ); + + mswDB.update( + 'linodes', + linodeAssigned.id, + { + placement_group: undefined, + }, + mockState + ); + + queueEvents({ + event: { + action: 'placement_group_unassign', + entity: { + id: placementGroup.id, + label: placementGroup.label, + type: 'placement_group', + url: `/v4/placement/groups/${placementGroup.id}`, + }, + secondary_entity: { + id: linodeAssigned.id, + label: linodeAssigned.label, + type: 'linode', + url: `/v4/linode/instances/${linodeAssigned.id}`, + }, + }, + mockState, + sequence: [{ status: 'notification' }], + }); + + return makeResponse(placementGroup); + } + ), +]; diff --git a/packages/manager/src/mocks/presets/crud/handlers/volumes.ts b/packages/manager/src/mocks/presets/crud/handlers/volumes.ts new file mode 100644 index 00000000000..80cb9d2b223 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/handlers/volumes.ts @@ -0,0 +1,229 @@ +import { DateTime } from 'luxon'; +import { http } from 'msw'; + +import { volumeFactory, volumeTypeFactory } from 'src/factories'; +import { mswDB } from 'src/mocks/indexedDB'; +import { + makeNotFoundResponse, + makePaginatedResponse, + makeResponse, +} from 'src/mocks/utilities/response'; + +import type { PriceType, Volume } from '@linode/api-v4'; +import type { StrictResponse } from 'msw'; +import type { MockState } from 'src/mocks/types'; +import type { + APIErrorResponse, + APIPaginatedResponse, +} from 'src/mocks/utilities/response'; + +export const getVolumes = (mockState: MockState) => [ + // Keeping things static for types/prices. + http.get( + '*/v4/volumes/types', + async ({ + request, + }): Promise< + StrictResponse> + > => { + const volumeTypes = volumeTypeFactory.buildList(3); + + return makePaginatedResponse({ + data: volumeTypes, + request, + }); + } + ), + + http.get( + '*/v4/volumes', + async ({ + request, + }): Promise< + StrictResponse> + > => { + const volumes = await mswDB.getAll('volumes'); + + if (!volumes) { + return makeNotFoundResponse(); + } + return makePaginatedResponse({ + data: volumes, + request, + }); + } + ), + + http.get( + '*/v4/volumes/:id', + async ({ params }): Promise> => { + const id = Number(params.id); + const volume = await mswDB.get('volumes', id); + + if (!volume) { + return makeNotFoundResponse(); + } + + return makeResponse(volume); + } + ), + + http.get( + '*/v4/linode/instances/:linodeId/volumes', + async ({ + params, + request, + }): Promise< + StrictResponse> + > => { + const linodeId = Number(params.linodeId); + const linode = await mswDB.get('linodes', linodeId); + + if (!linode) { + return makeNotFoundResponse(); + } + + const volumesForLinode = mockState.volumes.filter( + (stateVolume) => stateVolume.linode_id === linodeId + ); + + return makePaginatedResponse({ + data: volumesForLinode, + request, + }); + } + ), +]; + +export const createVolumes = (mockState: MockState) => [ + http.post( + '*/v4/volumes', + async ({ request }): Promise> => { + const payload = await request.clone().json(); + const linodeId = payload['linode_id']; + + let volumeLinodePayloadData: Partial = { + linode_id: null, + }; + + if (linodeId) { + const linode = await mswDB.get('linodes', linodeId); + + if (!linode) { + return makeNotFoundResponse(); + } + + volumeLinodePayloadData = { + linode_id: linode.id, + linode_label: linode.label, + }; + } + + const volume = volumeFactory.build({ + created: DateTime.now().toISO(), + label: payload['label'], + region: payload['region'], + size: payload['size'], + updated: DateTime.now().toISO(), + ...volumeLinodePayloadData, + }); + + await mswDB.add('volumes', volume, mockState); + + // TODO queue event. + return makeResponse(volume); + } + ), +]; + +export const updateVolumes = (mockState: MockState) => [ + http.put( + '*/v4/volumes/:id', + async ({ + params, + request, + }): Promise> => { + const id = Number(params.id); + const volume = await mswDB.get('volumes', id); + + if (!volume) { + return makeNotFoundResponse(); + } + + const payload = await request.clone().json(); + const updatedVolume = { ...volume, ...payload }; + + await mswDB.update('volumes', id, updatedVolume, mockState); + + // TODO queue event. + return makeResponse(updatedVolume); + } + ), + + http.post( + '*/v4/volumes/:id/attach', + async ({ + params, + request, + }): Promise> => { + const id = Number(params.id); + const volume = await mswDB.get('volumes', id); + const payload = await request.clone().json(); + const linodeId = payload.linode_id; + const linode = await mswDB.get('linodes', linodeId); + + if (!volume) { + return makeNotFoundResponse(); + } + + const updatedVolume: Volume = { + ...volume, + ...payload, + linode_label: linode?.label, + }; + + await mswDB.update('volumes', id, updatedVolume, mockState); + + // TODO queue event. + return makeResponse(updatedVolume); + } + ), + + http.post( + '*/v4/volumes/:id/detach', + async ({ params }): Promise> => { + const id = Number(params.id); + const volume = await mswDB.get('volumes', id); + + if (!volume) { + return makeNotFoundResponse(); + } + + const updatedVolume = { ...volume, linode_id: null, linode_label: null }; + + await mswDB.update('volumes', id, updatedVolume, mockState); + + // TODO queue event. + return makeResponse({}); + } + ), +]; + +export const deleteVolumes = (mockState: MockState) => [ + http.delete( + '*/v4/volumes/:id', + async ({ params }): Promise> => { + const id = Number(params.id); + const volume = await mswDB.get('volumes', id); + + if (!volume) { + return makeNotFoundResponse(); + } + + await mswDB.delete('volumes', id, mockState); + + // TODO queue event. + return makeResponse({}); + } + ), +]; diff --git a/packages/manager/src/mocks/presets/crud/linodes.ts b/packages/manager/src/mocks/presets/crud/linodes.ts new file mode 100644 index 00000000000..25912192234 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/linodes.ts @@ -0,0 +1,34 @@ +import { + createLinode, + deleteLinode, + getLinodeBackups, + getLinodeDisks, + getLinodeFirewalls, + getLinodeIps, + getLinodeStats, + getLinodeTransfer, + getLinodes, + shutDownLinode, + updateLinode, +} from 'src/mocks/presets/crud/handlers/linodes'; + +import type { MockPresetCrud } from 'src/mocks/types'; + +export const linodeCrudPreset: MockPresetCrud = { + group: { id: 'Linodes' }, + handlers: [ + getLinodes, + createLinode, + updateLinode, + deleteLinode, + getLinodeStats, + getLinodeDisks, + getLinodeFirewalls, + getLinodeTransfer, + getLinodeIps, + getLinodeBackups, + shutDownLinode, + ], + id: 'linodes:crud', + label: 'Linode CRUD', +}; diff --git a/packages/manager/src/mocks/presets/crud/placementGroups.ts b/packages/manager/src/mocks/presets/crud/placementGroups.ts new file mode 100644 index 00000000000..fec0e460b01 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/placementGroups.ts @@ -0,0 +1,22 @@ +import { + createPlacementGroup, + deletePlacementGroup, + getPlacementGroups, + placementGroupLinodeAssignment, + updatePlacementGroup, +} from 'src/mocks/presets/crud/handlers/placementGroups'; + +import type { MockPresetCrud } from 'src/mocks/types'; + +export const placementGroupsCrudPreset: MockPresetCrud = { + group: { id: 'Placement Groups' }, + handlers: [ + createPlacementGroup, + getPlacementGroups, + updatePlacementGroup, + deletePlacementGroup, + placementGroupLinodeAssignment, + ], + id: 'placement-groups:crud', + label: 'Placement Groups CRUD', +}; diff --git a/packages/manager/src/mocks/presets/crud/seeds/index.ts b/packages/manager/src/mocks/presets/crud/seeds/index.ts new file mode 100644 index 00000000000..ca21c1dff0a --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/seeds/index.ts @@ -0,0 +1,5 @@ +import { linodesSeeder } from './linodes'; +import { placementGroupSeeder } from './placementGroups'; +import { volumesSeeder } from './volumes'; + +export const dbSeeders = [linodesSeeder, placementGroupSeeder, volumesSeeder]; diff --git a/packages/manager/src/mocks/presets/crud/seeds/linodes.ts b/packages/manager/src/mocks/presets/crud/seeds/linodes.ts new file mode 100644 index 00000000000..6a3f874fafd --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/seeds/linodes.ts @@ -0,0 +1,38 @@ +import { getSeedsCountMap } from 'src/dev-tools/utils'; +import { configFactory, linodeFactory } from 'src/factories'; +import { mswDB } from 'src/mocks/indexedDB'; +import { seedWithUniqueIds } from 'src/mocks/presets/crud/seeds/utils'; + +import type { Config } from '@linode/api-v4'; +import type { MockSeeder, MockState } from 'src/mocks/types'; + +export const linodesSeeder: MockSeeder = { + canUpdateCount: true, + desc: 'Linodes Seeds', + group: { id: 'Linodes' }, + id: 'linodes:crud', + label: 'Linodes', + + seeder: async (mockState: MockState) => { + const seedsCountMap = getSeedsCountMap(); + const count = seedsCountMap[linodesSeeder.id] ?? 0; + const linodeSeeds = seedWithUniqueIds<'linodes'>({ + dbEntities: await mswDB.getAll('linodes'), + seedEntities: linodeFactory.buildList(count), + }); + + const configs: [number, Config][] = linodeSeeds.map((linodeSeed) => { + return [linodeSeed.id, configFactory.build()]; + }); + + const updatedMockState = { + ...mockState, + linodeConfigs: mockState.linodeConfigs.concat(configs), + linodes: mockState.linodes.concat(linodeSeeds), + }; + + await mswDB.saveStore(updatedMockState, 'seedState'); + + return updatedMockState; + }, +}; diff --git a/packages/manager/src/mocks/presets/crud/seeds/placementGroups.ts b/packages/manager/src/mocks/presets/crud/seeds/placementGroups.ts new file mode 100644 index 00000000000..51032653313 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/seeds/placementGroups.ts @@ -0,0 +1,35 @@ +import { getSeedsCountMap } from 'src/dev-tools/utils'; +import { placementGroupFactory } from 'src/factories'; +import { mswDB } from 'src/mocks/indexedDB'; +import { seedWithUniqueIds } from 'src/mocks/presets/crud/seeds/utils'; + +import type { MockSeeder, MockState } from 'src/mocks/types'; + +export const placementGroupSeeder: MockSeeder = { + canUpdateCount: true, + desc: 'Placement Groups Seeds', + group: { id: 'Placement Groups' }, + id: 'placement-groups:crud', + label: 'Placement Groups', + + seeder: async (mockState: MockState) => { + const seedsCountMap = getSeedsCountMap(); + const count = seedsCountMap[placementGroupSeeder.id] ?? 0; + const placementGroupSeeds = seedWithUniqueIds<'placementGroups'>({ + dbEntities: await mswDB.getAll('placementGroups'), + seedEntities: placementGroupFactory.buildList(count, { + is_compliant: true, + members: [], + }), + }); + + const updatedMockState = { + ...mockState, + placementGroups: mockState.placementGroups.concat(placementGroupSeeds), + }; + + await mswDB.saveStore(updatedMockState, 'seedState'); + + return updatedMockState; + }, +}; diff --git a/packages/manager/src/mocks/presets/crud/seeds/utils.ts b/packages/manager/src/mocks/presets/crud/seeds/utils.ts new file mode 100644 index 00000000000..348c6dd6c23 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/seeds/utils.ts @@ -0,0 +1,91 @@ +import { mockState } from 'src/dev-tools/load'; +import { mswDB } from 'src/mocks/indexedDB'; + +import type { MockSeeder, MockState } from 'src/mocks/types'; + +/** + * Removes the seeds from the database. + * + * @param seederId - The ID of the seeder to remove. + * + * @returns The mock state with the seeds removed. + */ +export const removeSeeds = async (seederId: MockSeeder['id']) => { + switch (seederId) { + case 'linodes:crud': + await mswDB.deleteAll('linodes', mockState, 'seedState'); + await mswDB.deleteAll('linodeConfigs', mockState, 'seedState'); + break; + case 'placement-groups:crud': + await mswDB.deleteAll('placementGroups', mockState, 'seedState'); + break; + case 'volumes:crud': + await mswDB.deleteAll('volumes', mockState, 'seedState'); + break; + default: + break; + } + + return mockState; +}; + +type WithId = { + id: number; +}; + +/** + * Type guard to check if an object has an 'id' property + * + * @param obj - The object to check. + * + * @returns True if the object has an 'id' property, false otherwise. + */ +export const hasId = (obj: any): obj is WithId => { + return 'id' in obj; +}; + +interface SeedWithUniqueIdsArgs { + dbEntities: MockState[T] | undefined; + seedEntities: MockState[T]; +} + +/** + * Ensures that the seed entities have unique IDs by incrementing them if they + * are already taken. + * + * @param dbEntities - The entities from the database. + * @param seedEntities - The entities to seed. + * + * @returns The seed entities with unique IDs. + */ +export const seedWithUniqueIds = ({ + dbEntities, + seedEntities, +}: SeedWithUniqueIdsArgs) => { + if (!dbEntities || dbEntities.length === 0) { + return seedEntities; + } + + const allEntities = [...dbEntities, ...seedEntities]; + + seedEntities.forEach((seedEntity) => { + if (!hasId(seedEntity)) { + return; + } + + let seedEntityId = seedEntity.id; + + while ( + allEntities.some( + // eslint-disable-next-line no-loop-func + (entity) => hasId(entity) && entity.id === seedEntityId + ) + ) { + seedEntityId++; + } + + seedEntity.id = seedEntityId; + }); + + return seedEntities; +}; diff --git a/packages/manager/src/mocks/presets/crud/seeds/volumes.ts b/packages/manager/src/mocks/presets/crud/seeds/volumes.ts new file mode 100644 index 00000000000..9ad574b1cf4 --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/seeds/volumes.ts @@ -0,0 +1,28 @@ +import { getSeedsCountMap } from 'src/dev-tools/utils'; +import { volumeFactory } from 'src/factories'; +import { mswDB } from 'src/mocks/indexedDB'; + +import type { MockSeeder, MockState } from 'src/mocks/types'; + +export const volumesSeeder: MockSeeder = { + canUpdateCount: true, + desc: 'Volumes Seeds', + group: { id: 'Volumes' }, + id: 'volumes:crud', + label: 'Volumes', + + seeder: async (mockState: MockState) => { + const seedsCountMap = getSeedsCountMap(); + const count = seedsCountMap[volumesSeeder.id] ?? 0; + const volumes = volumeFactory.buildList(count); + + const updatedMockState = { + ...mockState, + volumes: mockState.volumes.concat(volumes), + }; + + await mswDB.saveStore(updatedMockState, 'seedState'); + + return updatedMockState; + }, +}; diff --git a/packages/manager/src/mocks/presets/crud/volumes.ts b/packages/manager/src/mocks/presets/crud/volumes.ts new file mode 100644 index 00000000000..c7dfe145cbd --- /dev/null +++ b/packages/manager/src/mocks/presets/crud/volumes.ts @@ -0,0 +1,15 @@ +import { + createVolumes, + deleteVolumes, + getVolumes, + updateVolumes, +} from 'src/mocks/presets/crud/handlers/volumes'; + +import type { MockPresetCrud } from 'src/mocks/types'; + +export const volumeCrudPreset: MockPresetCrud = { + group: { id: 'Volumes' }, + handlers: [createVolumes, deleteVolumes, updateVolumes, getVolumes], + id: 'volumes:crud', + label: 'Volumes CRUD', +}; diff --git a/packages/manager/src/mocks/presets/extra/account/childAccount.ts b/packages/manager/src/mocks/presets/extra/account/childAccount.ts new file mode 100644 index 00000000000..e27be59d127 --- /dev/null +++ b/packages/manager/src/mocks/presets/extra/account/childAccount.ts @@ -0,0 +1,49 @@ +import { http } from 'msw'; + +import { + accountFactory, + accountUserFactory, + profileFactory, +} from 'src/factories'; +import { makeResponse } from 'src/mocks/utilities/response'; + +import type { MockPresetExtra } from 'src/mocks/types'; + +/** + * Mocks account, profile, and user requests to simulate a Parent/Child child account user. + */ +const mockChildAccount = () => { + return [ + http.get('*/v4*/account', () => { + return makeResponse( + accountFactory.build({ + company: 'Partner Company', + }) + ); + }), + http.get('*/v4*/profile', () => { + return makeResponse( + profileFactory.build({ + user_type: 'proxy', + username: 'Parent Account User', + }) + ); + }), + http.get(`*/v4*/account/users/Parent Account User`, () => { + return makeResponse( + accountUserFactory.build({ + user_type: 'proxy', + username: 'Parent Account User', + }) + ); + }), + ]; +}; + +export const childAccountPreset: MockPresetExtra = { + desc: 'Mock a Parent/Child child account proxy user', + group: { id: 'Account', type: 'select' }, + handlers: [mockChildAccount], + id: 'account:child-proxy', + label: 'Child Account Proxy User', +}; diff --git a/packages/manager/src/mocks/presets/extra/account/managedDisabled.ts b/packages/manager/src/mocks/presets/extra/account/managedDisabled.ts new file mode 100644 index 00000000000..c5d336d8ab4 --- /dev/null +++ b/packages/manager/src/mocks/presets/extra/account/managedDisabled.ts @@ -0,0 +1,26 @@ +import { http } from 'msw'; + +import { accountSettingsFactory } from 'src/factories'; +import { makeResponse } from 'src/mocks/utilities/response'; + +import type { MockPresetExtra } from 'src/mocks/types'; + +const mockManagedDisabledAccount = () => { + return [ + http.get('*/v4*/account/settings', () => { + return makeResponse( + accountSettingsFactory.build({ + managed: false, + }) + ); + }), + ]; +}; + +export const managedDisabledPreset: MockPresetExtra = { + desc: 'Mock account settings to disable Linode Managed', + group: { id: 'Managed', type: 'select' }, + handlers: [mockManagedDisabledAccount], + id: 'account:managed-disabled', + label: 'Managed Disabled', +}; diff --git a/packages/manager/src/mocks/presets/extra/account/managedEnabled.ts b/packages/manager/src/mocks/presets/extra/account/managedEnabled.ts new file mode 100644 index 00000000000..1493ee7e635 --- /dev/null +++ b/packages/manager/src/mocks/presets/extra/account/managedEnabled.ts @@ -0,0 +1,26 @@ +import { http } from 'msw'; + +import { accountSettingsFactory } from 'src/factories'; +import { makeResponse } from 'src/mocks/utilities/response'; + +import type { MockPresetExtra } from 'src/mocks/types'; + +const mockManagedEnabledAccount = () => { + return [ + http.get('*/v4*/account/settings', () => { + return makeResponse( + accountSettingsFactory.build({ + managed: true, + }) + ); + }), + ]; +}; + +export const managedEnabledPreset: MockPresetExtra = { + desc: 'Mock account settings to enable Linode Managed', + group: { id: 'Managed', type: 'select' }, + handlers: [mockManagedEnabledAccount], + id: 'account:managed-enabled', + label: 'Managed Enabled', +}; diff --git a/packages/manager/src/mocks/presets/extra/account/parentAccount.ts b/packages/manager/src/mocks/presets/extra/account/parentAccount.ts new file mode 100644 index 00000000000..d3906e30a38 --- /dev/null +++ b/packages/manager/src/mocks/presets/extra/account/parentAccount.ts @@ -0,0 +1,52 @@ +import { http } from 'msw'; + +import { + accountFactory, + accountUserFactory, + profileFactory, +} from 'src/factories'; +import { makeResponse } from 'src/mocks/utilities/response'; + +import type { MockPresetExtra } from 'src/mocks/types'; + +/** + * Mocks account, profile, and user requests to simulate a Parent/Child parent account user. + */ +const mockParentAccount = () => { + return [ + http.get('*/v4*/account', () => { + return makeResponse( + accountFactory.build({ + company: 'Parent Company', + }) + ); + }), + http.get('*/v4*/profile', () => { + return makeResponse( + profileFactory.build({ + user_type: 'parent', + username: 'Parent Account User', + }) + ); + }), + http.get(`*/v4*/account/users/Parent Account User`, () => { + return makeResponse( + accountUserFactory.build({ + user_type: 'parent', + username: 'Parent Account User', + }) + ); + }), + ]; +}; + +export const parentAccountPreset: MockPresetExtra = { + desc: 'Mock a Parent/Child parent account user', + group: { + id: 'Account', + type: 'select', + }, + handlers: [mockParentAccount], + id: 'account:parent', + label: 'Parent Account User', +}; diff --git a/packages/manager/src/mocks/presets/extra/api/api.ts b/packages/manager/src/mocks/presets/extra/api/api.ts new file mode 100644 index 00000000000..8f15f338d9e --- /dev/null +++ b/packages/manager/src/mocks/presets/extra/api/api.ts @@ -0,0 +1,35 @@ +import { http } from 'msw'; + +import { getExtraPresetsMap } from 'src/dev-tools/utils'; + +import type { MockPresetExtra } from 'src/mocks/types'; + +const APIResponseTime = () => { + const extraPresetsMap = getExtraPresetsMap(); + const responseTime = extraPresetsMap[apiResponseTimePreset.id] || 0; + + return [ + http.all('*/v4*/*', async () => { + // Simulating a 500ms delay for all requests + // to make the UI feel more realistic (e.g. loading states) + await new Promise((resolve) => { + const timer = setTimeout(resolve, responseTime); + // Clear the timer if the request is aborted + // to avoid any potential memory leaks + return () => clearTimeout(timer); + }); + }), + ]; +}; + +export const apiResponseTimePreset: MockPresetExtra = { + canUpdateCount: true, + desc: 'Allows to customize API response time', + group: { + id: 'API', + type: 'checkbox', + }, + handlers: [APIResponseTime], + id: 'api:response-time', + label: 'Response Time (ms)', +}; diff --git a/packages/manager/src/mocks/presets/extra/regions/coreAndDistributed.ts b/packages/manager/src/mocks/presets/extra/regions/coreAndDistributed.ts new file mode 100644 index 00000000000..a0e36fc3ab4 --- /dev/null +++ b/packages/manager/src/mocks/presets/extra/regions/coreAndDistributed.ts @@ -0,0 +1,26 @@ +import { http } from 'msw'; + +import { distributedRegions } from 'src/__data__/distributedRegionsData'; +import { productionRegions } from 'src/__data__/productionRegionsData'; +import { makePaginatedResponse } from 'src/mocks/utilities/response'; + +import type { MockPresetExtra } from 'src/mocks/types'; + +const mockCoreAndDistributedRegions = () => { + return [ + http.get('*/v4/regions', ({ request }) => { + return makePaginatedResponse({ + data: [...productionRegions, ...distributedRegions], + request, + }); + }), + ]; +}; + +export const coreAndDistributedRegionsPreset: MockPresetExtra = { + desc: 'Core and Distributed Regions', + group: { id: 'Regions', type: 'select' }, + handlers: [mockCoreAndDistributedRegions], + id: 'regions:core-and-distributed', + label: 'Core + Distributed', +}; diff --git a/packages/manager/src/mocks/presets/extra/regions/coreOnly.ts b/packages/manager/src/mocks/presets/extra/regions/coreOnly.ts new file mode 100644 index 00000000000..4be5a00bc55 --- /dev/null +++ b/packages/manager/src/mocks/presets/extra/regions/coreOnly.ts @@ -0,0 +1,25 @@ +import { http } from 'msw'; + +import { productionRegions } from 'src/__data__/productionRegionsData'; +import { makePaginatedResponse } from 'src/mocks/utilities/response'; + +import type { MockPresetExtra } from 'src/mocks/types'; + +const mockCoreOnlyRegions = () => { + return [ + http.get('*/v4/regions', ({ request }) => { + return makePaginatedResponse({ + data: productionRegions, + request, + }); + }), + ]; +}; + +export const coreOnlyRegionsPreset: MockPresetExtra = { + desc: 'Core Only Regions', + group: { id: 'Regions', type: 'select' }, + handlers: [mockCoreOnlyRegions], + id: 'regions:core-only', + label: 'Core Only', +}; diff --git a/packages/manager/src/mocks/presets/extra/regions/legacyRegions.ts b/packages/manager/src/mocks/presets/extra/regions/legacyRegions.ts new file mode 100644 index 00000000000..8aa91a808f9 --- /dev/null +++ b/packages/manager/src/mocks/presets/extra/regions/legacyRegions.ts @@ -0,0 +1,25 @@ +import { http } from 'msw'; + +import { regions } from 'src/__data__/regionsData'; +import { makePaginatedResponse } from 'src/mocks/utilities/response'; + +import type { MockPresetExtra } from 'src/mocks/types'; + +const mockLegacyRegions = () => { + return [ + http.get('*/v4/regions', ({ request }) => { + return makePaginatedResponse({ + data: regions, + request, + }); + }), + ]; +}; + +export const legacyRegionsPreset: MockPresetExtra = { + desc: 'Legacy regions', + group: { id: 'Regions', type: 'select' }, + handlers: [mockLegacyRegions], + id: 'regions:legacy', + label: 'Legacy', +}; diff --git a/packages/manager/src/mocks/presets/index.ts b/packages/manager/src/mocks/presets/index.ts new file mode 100644 index 00000000000..213b42732ba --- /dev/null +++ b/packages/manager/src/mocks/presets/index.ts @@ -0,0 +1,53 @@ +import { baselineAccountActivationPreset } from './baseline/accountReview'; +import { baselineApiMaintenanceModePreset } from './baseline/apiMaintenanceMode'; +import { baselineApiOfflinePreset } from './baseline/apiOffline'; +import { baselineApiUnstablePreset } from './baseline/apiUnstable'; +import { baselineCrudPreset } from './baseline/crud'; +import { baselineLegacyPreset } from './baseline/legacy'; +import { baselineNoMocksPreset } from './baseline/noMocks'; +import { childAccountPreset } from './extra/account/childAccount'; +import { managedDisabledPreset } from './extra/account/managedDisabled'; +import { managedEnabledPreset } from './extra/account/managedEnabled'; +import { parentAccountPreset } from './extra/account/parentAccount'; +import { apiResponseTimePreset } from './extra/api/api'; +import { coreAndDistributedRegionsPreset } from './extra/regions/coreAndDistributed'; +import { coreOnlyRegionsPreset } from './extra/regions/coreOnly'; +import { legacyRegionsPreset } from './extra/regions/legacyRegions'; + +import type { MockPresetBaseline, MockPresetExtra } from '../types'; + +/** The preset that we fall back on if the local storage value is unset or invalid. */ +export const defaultBaselineMockPreset = baselineNoMocksPreset; + +/** Baseline mock presets get applied before all other presets, and one must be selected. */ +export const baselineMockPresets: MockPresetBaseline[] = [ + baselineNoMocksPreset, + baselineCrudPreset, + baselineLegacyPreset, + baselineAccountActivationPreset, + baselineApiMaintenanceModePreset, + baselineApiOfflinePreset, + baselineApiUnstablePreset, +]; + +/** + * Extra mock presets can be used to conditionally apply extra functionality via mocks. + * + * ⚠️ The order here is the order shown in the dev tools. + * */ +export const extraMockPresets: MockPresetExtra[] = [ + apiResponseTimePreset, + parentAccountPreset, + childAccountPreset, + managedEnabledPreset, + managedDisabledPreset, + coreAndDistributedRegionsPreset, + coreOnlyRegionsPreset, + legacyRegionsPreset, +]; + +/** An array of all mock presets. */ +export const allMockPresets: (MockPresetBaseline | MockPresetExtra)[] = [ + ...baselineMockPresets, + ...extraMockPresets, +]; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index a389f956671..455489fe6a9 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1,3 +1,11 @@ +/** + * @deprecated + * + * This mocking mode is being phased out. + * It remains available in out DEV tools for convenience and backward compatibility, it is however discouraged to add new handlers to it. + * + * New handlers should be added to the CRUD baseline preset instead (ex: src/mocks/presets/crud/handlers/linodes.ts) which support a much more dynamic data mocking. + */ import { DateTime } from 'luxon'; import { HttpResponse, http } from 'msw'; diff --git a/packages/manager/src/mocks/testBrowser.ts b/packages/manager/src/mocks/testBrowser.ts deleted file mode 100644 index 50b3dbe82ec..00000000000 --- a/packages/manager/src/mocks/testBrowser.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { setupWorker } from 'msw/browser'; - -import { handlers } from './serverHandlers'; - -export const worker = setupWorker(...handlers); diff --git a/packages/manager/src/mocks/types.ts b/packages/manager/src/mocks/types.ts new file mode 100644 index 00000000000..8d77e2a98e4 --- /dev/null +++ b/packages/manager/src/mocks/types.ts @@ -0,0 +1,102 @@ +import type { + Config, + Event, + Firewall, + Linode, + Notification, + PlacementGroup, + Region, + RegionAvailability, + Volume, +} from '@linode/api-v4'; +import type { HttpHandler } from 'msw'; + +export type MockPresetBase = { + /** Description of mock preset and its purpose. */ + desc?: string; + + /** Array of MSW handler generator functions. */ + handlers: MockHandler[]; + + /** Human-readable label for mock preset. */ + label: string; +}; + +/** + * Mock Preset Baseline + */ +export type MockPresetBaselineGroup = { + id: 'API State' | 'Account State' | 'General'; +}; +export type MockPresetBaselineId = + | 'baseline:account-activation' + | 'baseline:api-maintenance' + | 'baseline:api-offline' + | 'baseline:api-unstable' + | 'baseline:crud' + | 'baseline:legacy' + | 'baseline:preset-mocking'; +export interface MockPresetBaseline extends MockPresetBase { + group: MockPresetBaselineGroup; + id: MockPresetBaselineId; +} + +/** + * Mock Preset Extra + */ +export type MockPresetExtraGroup = { + id: 'API' | 'Account' | 'Managed' | 'Regions'; + type: 'checkbox' | 'select'; +}; +export type MockPresetExtraId = + | 'account:child-proxy' + | 'account:managed-disabled' + | 'account:managed-enabled' + | 'account:parent' + | 'api:response-time' + | 'regions:core-and-distributed' + | 'regions:core-only' + | 'regions:legacy'; +export interface MockPresetExtra extends MockPresetBase { + canUpdateCount?: boolean; + group: MockPresetExtraGroup; + id: MockPresetExtraId; +} + +/** + * Mock Preset Crud + */ +export type MockPresetCrudGroup = { + id: 'Linodes' | 'Placement Groups' | 'Volumes'; +}; +export type MockPresetCrudId = + | 'linodes:crud' + | 'placement-groups:crud' + | 'volumes:crud'; +export interface MockPresetCrud extends MockPresetBase { + canUpdateCount?: boolean; + group: MockPresetCrudGroup; + id: MockPresetCrudId; +} + +export type MockHandler = (mockState: MockState) => HttpHandler[]; + +/** + * Stateful data shared among mocks. + */ +export interface MockState { + eventQueue: Event[]; + firewalls: Firewall[]; + linodeConfigs: [number, Config][]; + linodes: Linode[]; + notificationQueue: Notification[]; + placementGroups: PlacementGroup[]; + regionAvailability: RegionAvailability[]; + regions: Region[]; + volumes: Volume[]; +} + +export interface MockSeeder extends Omit { + /** Function that updates the mock state. */ + seeder: (mockState: MockState) => MockState | Promise; +} diff --git a/packages/manager/src/mocks/utilities/events.ts b/packages/manager/src/mocks/utilities/events.ts new file mode 100644 index 00000000000..5c8b24a4286 --- /dev/null +++ b/packages/manager/src/mocks/utilities/events.ts @@ -0,0 +1,101 @@ +import { eventFactory } from 'src/factories'; +import { mswDB } from '../indexedDB'; + +import type { Event } from '@linode/api-v4'; +import type { MockState } from 'src/mocks/types'; + +interface QueuedEvents { + event: { + action: Event['action']; + entity?: Event['entity']; + secondary_entity?: Event['secondary_entity']; + }; + mockState: MockState; + sequence: { + isProgressEvent?: boolean; + status: Event['status']; + }[]; +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Queues a series of events to be processed in sequence. + * + * The sequence is sorted by the natural order of our statuses: 'scheduled', 'started', 'finished | notification | failed'. + * Events can be marked as progress events, which will increase their duration as well as the following event's delay. + */ +export const queueEvents = (props: QueuedEvents): Promise => { + const { event, mockState, sequence } = props; + + const initialDelay = 7500; + const progressDelay = 10_000; + let accumulatedDelay = 0; + let lastEventWasProgress = false; + + const processEventSequence = async (index: number): Promise => { + if (index >= sequence.length) { + return; + } + + const seq = sequence[index]; + const eventDelay = + index > 0 + ? lastEventWasProgress + ? progressDelay + initialDelay + : initialDelay + : 0; + + accumulatedDelay += eventDelay; + lastEventWasProgress = seq.isProgressEvent || false; + + await delay(accumulatedDelay); + + const sequenceEvent = eventFactory.build({ + ...event, + created: new Date().toISOString(), + duration: null, + percent_complete: seq.isProgressEvent ? 0 : null, + rate: null, + read: false, + seen: false, + status: seq.status, + }); + + // Add the new event to the database (store only serializable data) + await mswDB.add('eventQueue', sequenceEvent, mockState); + + // Handle progress events separately + if (seq.isProgressEvent) { + const intervalId = setInterval(async () => { + try { + const updatedEvent = await mswDB.get('eventQueue', sequenceEvent.id); + + if (updatedEvent) { + updatedEvent.percent_complete! += 10; + if (updatedEvent.percent_complete! >= 100) { + clearInterval(intervalId); + } + + await mswDB.update( + 'eventQueue', + updatedEvent.id, + updatedEvent, + mockState + ); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error updating event progress:', error); + + return; + } + }, 1000); + } + + // Recursively process the next event in the sequence + await processEventSequence(index + 1); + }; + + return processEventSequence(0); +}; diff --git a/packages/manager/src/mocks/utilities/pagination.ts b/packages/manager/src/mocks/utilities/pagination.ts new file mode 100644 index 00000000000..0737f40a8c8 --- /dev/null +++ b/packages/manager/src/mocks/utilities/pagination.ts @@ -0,0 +1,17 @@ +/** + * Returns a slice of `allData` corresponding to a page of paginated data. + * + * @param allData - Data from which to get paginated slice. + * @param page - Page to retrieve. + * @param pageSize - Number of results in one page. + */ +export const getPaginatedSlice = ( + allData: T[], + page: number, + pageSize: number = 500 +) => { + const dataStart = Math.min((page - 1) * pageSize, allData.length); + const dataEnd = Math.min(page * pageSize, allData.length); + + return allData.slice(dataStart, dataEnd); +}; diff --git a/packages/manager/src/mocks/utilities/response.ts b/packages/manager/src/mocks/utilities/response.ts new file mode 100644 index 00000000000..c690cb1905a --- /dev/null +++ b/packages/manager/src/mocks/utilities/response.ts @@ -0,0 +1,162 @@ +import { HttpResponse } from 'msw'; + +import { getPaginatedSlice } from '../utilities/pagination'; + +import type { APIError } from '@linode/api-v4'; +import type { JsonBodyType } from 'msw'; + +export interface APIPaginatedResponse { + data: T[]; + page: number; + pages: number; + results: number; +} + +export interface APIErrorResponse { + errors: APIError[]; +} + +/** + * Builds a Mock Service Worker response. + * + * @param body - The body to return in the response. + * @param status - The status code. + * @param headers - The headers to return. + * + * @returns A response. + */ +export const makeResponse = ( + body: T, + status: number = 200, + headers: {} = {} +) => { + return HttpResponse.json(body, { + headers, + status, + }); +}; + +/** + * Builds a Mock Service Worker error response. + * + * @param reason - The reason for the error. + * @param status - The status code. + * @param headers - The headers to return. + * + * @returns An error response. + */ +export const makeErrorResponse = ( + reason: string | string[] = 'An unexpected error occurred.', + status: number = 400, + headers: {} = {} +) => { + const reasonsArray = Array.isArray(reason) ? reason : [reason]; + + return HttpResponse.json( + { + errors: reasonsArray.map((reasonString) => ({ + reason: reasonString, + })), + }, + { + headers, + status, + } + ); +}; + +interface PaginatedResponse { + data: T[]; + request: Request; +} + +/** + * Builds a Mock Service Worker paginated response. + * This will probably need to be expanded to support more complex sorting and filtering but this will solve the common use case. + * + * @param data - The data to return in the response. + * @param request - The request that triggered the response. + * + * @returns A paginated response with X-Filter sort and filter logic. + */ +export const makePaginatedResponse = ({ + data, + request, +}: PaginatedResponse) => { + const dataArray = Array.isArray(data) ? data : [data]; + const url = new URL(request.url); + const requestedPage = Number(url.searchParams.get('page')) || 1; + const pageSize = Number(url.searchParams.get('page_size')) || 25; + const xFilter = request.headers.get('X-Filter'); + const filter = xFilter ? JSON.parse(xFilter) : {}; + const orderBy = filter['+order_by'] || 'id'; + const order = filter['+order'] || 'asc'; + const containsFilters = Object.entries(filter).filter( + ([_, value]) => + typeof value === 'object' && value !== null && '+contains' in value + ); + + // Filter the data based on the contains X-Filter + const filteredData = + containsFilters.length > 0 + ? dataArray.filter((item) => + containsFilters.every(([key, value]) => { + const searchValue = (value as { '+contains': string })[ + '+contains' + ].toLowerCase(); + return ( + typeof item === 'object' && + item !== null && + key in item && + String(item).toLowerCase().includes(searchValue) + ); + }) + ) + : dataArray; + + // Sort the data based on the order_by X-Filter + // with type guards to ensure that the data is of the expected type + filteredData.sort((a, b) => { + if ( + !a || + !b || + !(orderBy in a) || + !(orderBy in b) || + typeof a !== 'object' || + typeof b !== 'object' + ) { + return 0; + } + + const aValue = a[orderBy]; + const bValue = b[orderBy]; + + if (aValue < bValue) { + return order === 'asc' ? -1 : 1; + } + if (aValue > bValue) { + return order === 'asc' ? 1 : -1; + } + return 0; + }); + + const results = filteredData.length; + const pageCount = Math.ceil(filteredData.length / pageSize); + const pageSlice = getPaginatedSlice(filteredData, requestedPage, pageSize); + + return HttpResponse.json({ + data: pageSlice, + page: requestedPage, + pages: pageCount, + results, + }); +}; + +/** + * Builds a Mock Service Worker not found response. + * + * @returns A not found response. + */ +export const makeNotFoundResponse = () => { + return makeErrorResponse('Not found', 404); +}; diff --git a/packages/manager/src/utilities/rootManager.test.ts b/packages/manager/src/utilities/rootManager.test.ts new file mode 100644 index 00000000000..8e21fd2a282 --- /dev/null +++ b/packages/manager/src/utilities/rootManager.test.ts @@ -0,0 +1,38 @@ +import { createRoot } from 'react-dom/client'; + +import { getRoot, rootInstances } from './rootManager'; + +vi.mock('react-dom/client', () => ({ + createRoot: vi.fn().mockImplementation((container) => ({ + _internalRoot: container, // Mock implementation detail + render: vi.fn(), + })), +})); + +describe('getRoot', () => { + beforeEach(() => { + vi.clearAllMocks(); + rootInstances.clear(); + }); + + it('should create a new root for a new container', () => { + const container = document.createElement('div'); + const root = getRoot(container); + + expect(createRoot).toHaveBeenCalledWith(container); + expect(rootInstances.get(container)).toBe(root); + expect(createRoot).toHaveBeenCalledTimes(1); + }); + + it('should return the existing root for an existing container', () => { + const container = document.createElement('div'); + // Call getRoot twice with the same container + const firstCallRoot = getRoot(container); + const secondCallRoot = getRoot(container); + + // createRoot should only have been called once + expect(createRoot).toHaveBeenCalledTimes(1); + expect(firstCallRoot).toBe(secondCallRoot); + expect(rootInstances.size).toBe(1); + }); +}); diff --git a/packages/manager/src/utilities/rootManager.ts b/packages/manager/src/utilities/rootManager.ts new file mode 100644 index 00000000000..313c1b83d80 --- /dev/null +++ b/packages/manager/src/utilities/rootManager.ts @@ -0,0 +1,21 @@ +import { createRoot } from 'react-dom/client'; + +import type { Root } from 'react-dom/client'; + +export const rootInstances = new Map(); + +/** + * This utility helps manage React roots efficiently, + * ensuring that only one root is created per container and allowing reuse of existing roots when possible. + * It's particularly useful in scenarios where you need to dynamically create and manage multiple React roots in an application. (In our case, the APP and DevTools) + */ +export const getRoot = (container: HTMLElement): Root => { + let root = rootInstances.get(container); + + if (!root) { + root = createRoot(container); + rootInstances.set(container, root); + } + + return root; +}; diff --git a/packages/manager/src/utilities/storage.ts b/packages/manager/src/utilities/storage.ts index 1dbf77ccc6b..063863e42cf 100644 --- a/packages/manager/src/utilities/storage.ts +++ b/packages/manager/src/utilities/storage.ts @@ -1,4 +1,4 @@ -import { shouldEnableDevTools } from 'src/dev-tools/load'; +import { shouldLoadDevTools } from 'src/dev-tools/load'; import type { StackScriptPayload } from '@linode/api-v4/lib/stackscripts/types'; import type { SupportTicketFormFields } from 'src/features/Support/SupportTickets/SupportTicketDialog'; @@ -217,7 +217,7 @@ export const { export const getEnvLocalStorageOverrides = () => { // This is broken into two logical branches so that local storage is accessed // ONLY if the dev tools are enabled and it's a development build. - if (shouldEnableDevTools && import.meta.env.DEV) { + if (shouldLoadDevTools && import.meta.env.DEV) { const localStorageOverrides = storage.devToolsEnv.get(); if (localStorageOverrides) { return localStorageOverrides; From f471eb02fcea7a2b2845e36a8a2ce38d9e3cbe8e Mon Sep 17 00:00:00 2001 From: Hussain Khalil <122488130+hkhalil-akamai@users.noreply.github.com> Date: Tue, 3 Sep 2024 13:21:51 -0400 Subject: [PATCH 07/67] test: [M3-8454] - Cypress test for Secure VMs firewall generation (#10802) * Add cypress test for generating firewall in Linode create flow * Add integration test for error encountered during firewall generation * Added changeset: Secure VMs firewall generation * Fix integration test (maybe?) * Fix broken assertion --- .../pr-10802-tests-1724271249856.md | 5 + .../create-linode-with-firewall.spec.ts | 181 +++++++++++++++++- .../cypress/support/intercepts/firewalls.ts | 40 +++- 3 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-10802-tests-1724271249856.md diff --git a/packages/manager/.changeset/pr-10802-tests-1724271249856.md b/packages/manager/.changeset/pr-10802-tests-1724271249856.md new file mode 100644 index 00000000000..c4448b1b467 --- /dev/null +++ b/packages/manager/.changeset/pr-10802-tests-1724271249856.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Secure VMs firewall generation ([#10802](https://github.com/linode/manager/pull/10802)) diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts index 099ffbe79ef..1659a07189c 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-firewall.spec.ts @@ -1,4 +1,8 @@ -import { linodeFactory, firewallFactory } from 'src/factories'; +import { + linodeFactory, + firewallFactory, + firewallTemplateFactory, +} from 'src/factories'; import { mockAppendFeatureFlags, mockGetFeatureFlagClientstream, @@ -10,6 +14,8 @@ import { import { mockGetFirewalls, mockCreateFirewall, + mockGetTemplate, + mockCreateFirewallError, } from 'support/intercepts/firewalls'; import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; @@ -179,4 +185,177 @@ describe('Create Linode with Firewall', () => { // Confirm toast notification should appear on Linode create. ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); }); + + /* + * - Mocks the internal header to enable the Generate Compliant Firewall banner. + * - Confirms that Firewall is reflected in create summary section. + * - Confirms that outgoing Linode Create API request specifies the selected Firewall to be attached. + */ + it('can generate and assign a compliant Firewall during Linode Create flow', () => { + cy.intercept( + { + middleware: true, + url: /\/v4(?:beta)?\/.*/, + }, + (req) => { + // Re-add internal-only header + req.on('response', (res) => { + res.headers['akamai-internal-account'] = '*'; + }); + } + ); + + const linodeRegion = chooseRegion({ capabilities: ['Cloud Firewall'] }); + + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + + const mockLinode = linodeFactory.build({ + id: randomNumber(), + label: randomLabel(), + region: linodeRegion.id, + }); + + const mockTemplate = firewallTemplateFactory.build({ + slug: 'akamai-non-prod', + }); + + mockCreateFirewall(mockFirewall).as('createFirewall'); + mockGetFirewalls([mockFirewall]).as('getFirewall'); + mockGetTemplate(mockTemplate).as('getTemplate'); + 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)); + + // Creating the linode without a firewall should display a warning. + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.disabled'); + + cy.findByLabelText( + 'I am authorized to create a Linode without a Cloud Firewall' + ).click(); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled'); + + cy.findByText('Generate Compliant Firewall').should('be.visible').click(); + + ui.dialog + .findByTitle('Generate an Akamai Compliant Firewall') + .should('be.visible') + .within(() => { + cy.findByText('Generate Firewall Now') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('Generating Firewall'); + cy.findByText('Complete!'); + cy.findByText('OK').should('be.visible').should('be.enabled').click(); + }); + cy.wait('@createFirewall'); + + cy.findByText(mockFirewall.label).should('be.visible'); + + // Confirm Firewall assignment indicator is shown in Linode summary. + cy.get('[data-qa-linode-create-summary]') + .scrollIntoView() + .within(() => { + cy.findByText('Firewall 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 firewallId = requestPayload['firewall_id']; + expect(firewallId).to.equal(mockFirewall.id); + }); + + // Confirm redirect to new Linode. + cy.url().should('endWith', `/linodes/${mockLinode.id}`); + // Confirm toast notification should appear on Linode create. + ui.toast.assertMessage(`Your Linode ${mockLinode.label} is being created.`); + }); + + /* + * - Mocks the internal header to enable the Generate Compliant Firewall banner. + * - Mocks an error response to the Create Firewall call. + */ + it('displays errors encountered while trying to generate a compliant firewall', () => { + cy.intercept( + { + middleware: true, + url: /\/v4(?:beta)?\/.*/, + }, + (req) => { + // Re-add internal-only header + req.on('response', (res) => { + res.headers['akamai-internal-account'] = '*'; + }); + } + ); + + const mockFirewall = firewallFactory.build({ + id: randomNumber(), + label: randomLabel(), + }); + + const mockTemplate = firewallTemplateFactory.build({ + slug: 'akamai-non-prod', + }); + + const mockError = 'Mock error'; + + mockGetFirewalls([mockFirewall]).as('getFirewall'); + mockGetTemplate(mockTemplate).as('getTemplate'); + mockCreateFirewallError(mockError).as('createFirewall'); + + cy.visitWithLogin('/linodes/create'); + + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .should('be.enabled'); + + cy.findByText('Generate Compliant Firewall').should('be.visible').click(); + + ui.dialog + .findByTitle('Generate an Akamai Compliant Firewall') + .should('be.visible') + .within(() => { + cy.findByText('Generate Firewall Now') + .should('be.visible') + .should('be.enabled') + .click(); + cy.findByText('Generating Firewall'); + cy.findByText(mockError); + cy.findByText('Retry').should('be.visible').should('be.enabled'); + cy.findByText('Close').should('be.visible').should('be.enabled'); + }); + cy.wait('@createFirewall'); + }); }); diff --git a/packages/manager/cypress/support/intercepts/firewalls.ts b/packages/manager/cypress/support/intercepts/firewalls.ts index 07e2fc7a097..7ece27835b7 100644 --- a/packages/manager/cypress/support/intercepts/firewalls.ts +++ b/packages/manager/cypress/support/intercepts/firewalls.ts @@ -1,10 +1,12 @@ /** * @file Cypress intercepts and mocks for Firewall API requests. */ -import type { Firewall } from '@linode/api-v4'; +import { makeErrorResponse } from 'support/util/errors'; import { apiMatcher } from 'support/util/intercepts'; import { paginateResponse } from 'support/util/paginate'; +import type { Firewall, FirewallTemplate } from '@linode/api-v4'; + /** * Intercepts GET request to fetch Firewalls. * @@ -44,6 +46,25 @@ export const mockCreateFirewall = ( return cy.intercept('POST', apiMatcher('networking/firewalls*'), firewall); }; +/** + * Intercepts POST request to create a Firewall and mocks an error response. + * + * @param errorMessage - Error message to be included in the mocked HTTP response. + * @param statusCode - HTTP status code for mocked error response. Default is `400`. + * + * @returns Cypress chainable. + */ +export const mockCreateFirewallError = ( + errorMessage: string, + statusCode: number = 400 +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`networking/firewalls*`), + makeErrorResponse(errorMessage, statusCode) + ); +}; + /** * Intercepts POST request to create a Firewall. * @@ -80,3 +101,20 @@ export const interceptUpdateFirewallLinodes = ( apiMatcher(`networking/firewalls/${firewallId}/devices`) ); }; + +/** + * Intercepts GET request to fetch a Firewall template and mocks response. + * + * @param template - Template with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetTemplate = ( + template: FirewallTemplate +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('networking/firewalls/templates/*'), + template + ); +}; From e7d17b156adb2543c72b5da9d9abdd294c8fb729 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:35:54 -0400 Subject: [PATCH 08/67] test: [M3-5858] - Cypress component tests (#10134) * Upgrade cypress-axe * Add initial component testing configuration and setup * Add a11y and component testing utils * Allow feature flags to be overridden via `cy.mountWithTheme` * Add simple BetaChip POC component tests * Add RegionSelect POC component test * Add "cy:component" command to root package.json --- package.json | 1 + packages/manager/cypress.config.ts | 15 + .../cypress/component/poc/beta-chip.spec.tsx | 28 + .../component/poc/region-select.spec.tsx | 480 ++++++++++++++++++ .../cypress/support/component/index.html | 14 + .../cypress/support/component/setup.tsx | 84 +++ packages/manager/cypress/support/index.d.ts | 7 +- .../cypress/support/intercepts/account.ts | 18 + .../cypress/support/util/accessibility.ts | 29 ++ .../cypress/support/util/components.ts | 104 ++++ packages/manager/package.json | 1 + .../components/RegionSelect/RegionOption.tsx | 1 + 12 files changed, 781 insertions(+), 1 deletion(-) create mode 100644 packages/manager/cypress/component/poc/beta-chip.spec.tsx create mode 100644 packages/manager/cypress/component/poc/region-select.spec.tsx create mode 100644 packages/manager/cypress/support/component/index.html create mode 100644 packages/manager/cypress/support/component/setup.tsx create mode 100644 packages/manager/cypress/support/util/accessibility.ts create mode 100644 packages/manager/cypress/support/util/components.ts diff --git a/package.json b/package.json index e24b790f9ec..700d09de67c 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "cy:e2e": "yarn workspace linode-manager cy:e2e", "cy:ci": "yarn cy:e2e", "cy:debug": "yarn workspace linode-manager cy:debug", + "cy:component": "yarn workspace linode-manager cy:component", "cy:rec-snap": "yarn workspace linode-manager cy:rec-snap", "changeset": "node scripts/changelog/changeset.mjs", "generate-changelogs": "node scripts/changelog/generate-changelogs.mjs", diff --git a/packages/manager/cypress.config.ts b/packages/manager/cypress.config.ts index f5edf432f71..48e5938ec3a 100644 --- a/packages/manager/cypress.config.ts +++ b/packages/manager/cypress.config.ts @@ -16,6 +16,7 @@ import { splitCypressRun } from './cypress/support/plugins/split-run'; import { enableJunitReport } from './cypress/support/plugins/junit-report'; import { generateTestWeights } from './cypress/support/plugins/generate-weights'; import { logTestTagInfo } from './cypress/support/plugins/test-tagging-info'; +import cypressViteConfig from './cypress/vite.config'; /** * Exports a Cypress configuration object. @@ -45,6 +46,20 @@ export default defineConfig({ retries: process.env['CI'] && !process.env['CY_TEST_DISABLE_RETRIES'] ? 2 : 0, experimentalMemoryManagement: true, + + component: { + devServer: { + framework: 'react', + bundler: 'vite', + viteConfig: cypressViteConfig, + }, + indexHtmlFile: './cypress/support/component/index.html', + supportFile: './cypress/support/component/setup.tsx', + specPattern: './cypress/component/**/*.spec.tsx', + viewportWidth: 500, + viewportHeight: 500, + }, + e2e: { experimentalRunAllSpecs: true, diff --git a/packages/manager/cypress/component/poc/beta-chip.spec.tsx b/packages/manager/cypress/component/poc/beta-chip.spec.tsx new file mode 100644 index 00000000000..962e9ccf2b4 --- /dev/null +++ b/packages/manager/cypress/component/poc/beta-chip.spec.tsx @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { BetaChip } from 'src/components/BetaChip/BetaChip'; +import { componentTests, visualTests } from 'support/util/components'; +import { checkComponentA11y } from 'support/util/accessibility'; + +componentTests('BetaChip', () => { + visualTests((mount) => { + it('renders "BETA" text indicator with primary color', () => { + mount(); + cy.findByText('beta').should('be.visible'); + }); + + it('renders "BETA" text indicator with default color', () => { + mount(); + cy.findByText('beta').should('be.visible'); + }); + + it('passes aXe check with primary color', () => { + mount(); + checkComponentA11y(); + }); + + it('passes aXe check with default color', () => { + mount(); + checkComponentA11y(); + }); + }); +}); diff --git a/packages/manager/cypress/component/poc/region-select.spec.tsx b/packages/manager/cypress/component/poc/region-select.spec.tsx new file mode 100644 index 00000000000..d3ad57eae07 --- /dev/null +++ b/packages/manager/cypress/component/poc/region-select.spec.tsx @@ -0,0 +1,480 @@ +import * as React from 'react'; +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { componentTests, visualTests } from 'support/util/components'; +import { checkComponentA11y } from 'support/util/accessibility'; +import { accountAvailabilityFactory, regionFactory } from 'src/factories'; +import { ui } from 'support/ui'; +import { mockGetAccountAvailability } from 'support/intercepts/account'; +import { createSpy } from 'support/util/components'; + +componentTests('RegionSelect', (mount) => { + beforeEach(() => { + mockGetAccountAvailability([]); + }); + + describe('Interactions', () => { + describe('Open menu', () => { + /* + * - Region selection drop-down can be opened by clicking arrow button. + */ + it('can open drop-down menu by clicking drop-down arrow', () => { + const region = regionFactory.build({ + capabilities: ['Object Storage'], + }); + + mount( + {}} + /> + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle(`${region.label} (${region.id})`) + .should('be.visible'); + }); + + /* + * - Region selection drop-down can be opened by typing into text field. + */ + it('can open menu by typing into text field', () => { + const region = regionFactory.build({ + capabilities: ['Object Storage'], + }); + + mount( + {}} + /> + ); + + // Focus text field by clicking "Region" label. + cy.findByText('Region').should('be.visible').click(); + + cy.focused().type(region.label[0]); + + ui.autocompletePopper + .findByTitle(`${region.label} (${region.id})`) + .should('be.visible'); + }); + }); + + describe('Close menu', () => { + /* + * - Region selection drop-down can be dismissed by pressing the ESC key. + */ + it('can close menu with ESC key', () => { + const region = regionFactory.build({ + capabilities: ['Object Storage'], + }); + + mount( + {}} + /> + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle(`${region.label} (${region.id})`) + .should('be.visible'); + + cy.get('input').type('{esc}'); + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + + it('can close autocomplete popper by clicking away', () => { + const region = regionFactory.build({ + capabilities: ['Object Storage'], + }); + mount( + <> + Other Element + {}} + /> + + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.autocompletePopper + .findByTitle(`${region.label} (${region.id})`) + .should('be.visible'); + + cy.get('#other-element').click(); + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + }); + + describe('Selection', () => { + const regionToPreselect = regionFactory.build(); + const regionToSelect = regionFactory.build(); + const otherRegions = regionFactory.buildList(10); + + const regions = [regionToPreselect, regionToSelect, ...otherRegions]; + + it('can select a region initially', () => { + mount( + {}} + /> + ); + + cy.get('input').should('have.attr', 'placeholder', 'Select a Region'); + cy.findByText('Region').should('be.visible').click(); + cy.focused().type(regionToSelect.label[0]); + + ui.autocompletePopper + .findByTitle(`${regionToSelect.label} (${regionToSelect.id})`) + .scrollIntoView() + .should('be.visible') + .click(); + + // Confirm that selection change is reflected by input field value, and that + // the autocomplete popper has been dismissed. + cy.get('input').should( + 'have.attr', + 'value', + `${regionToSelect.label} (${regionToSelect.id})` + ); + + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + + /* + * - User can can selection after having already selected a region. + */ + it('can change region selection', () => { + mount( + {}} + /> + ); + + cy.get('input').should( + 'have.attr', + 'value', + `${regionToPreselect.label} (${regionToPreselect.id})` + ); + + cy.findByText('Region').should('be.visible').click(); + + cy.focused().type(regionToSelect.label[0]); + + ui.autocompletePopper + .findByTitle(`${regionToSelect.label} (${regionToSelect.id})`) + .scrollIntoView() + .should('be.visible') + .click(); + + cy.get('input').should( + 'have.attr', + 'value', + `${regionToSelect.label} (${regionToSelect.id})` + ); + + cy.get('[data-qa-autocomplete-popper]').should('not.exist'); + }); + + it('can clear region selection', () => { + mount( + {}} + /> + ); + + cy.get('input').should( + 'have.attr', + 'value', + `${regionToSelect.label} (${regionToSelect.id})` + ); + + cy.findByLabelText('Clear') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.get('input').should('have.attr', 'value', ''); + + cy.get('input').should('have.attr', 'placeholder', 'Select a Region'); + }); + + it('cannot clear region selection when clearable is disabled', () => { + mount( + {}} + /> + ); + + cy.get('input').should( + 'have.attr', + 'value', + `${regionToSelect.label} (${regionToSelect.id})` + ); + + cy.findByLabelText('Clear').should('not.exist'); + }); + + it('cannot clear region selection when no region is selected', () => { + mount( + {}} + /> + ); + + cy.get('input').should('have.attr', 'value', ''); + + cy.get('input').should('have.attr', 'placeholder', 'Select a Region'); + cy.findByLabelText('Clear').should('not.exist'); + }); + + it('calls `onChange` callback when region is initially selected', () => { + const spyFn = createSpy(() => {}, 'changeSpy'); + mount( + + ); + + cy.findByText('Region').should('be.visible').click(); + + cy.focused().type(regionToSelect.label[0]); + + ui.autocompletePopper + .findByTitle(`${regionToSelect.label} (${regionToSelect.id})`) + .scrollIntoView() + .should('be.visible') + .click(); + + cy.get('@changeSpy').should('have.been.calledOnce'); + }); + + it('calls `onChange` callback when region is cleared', () => { + const spyFn = createSpy(() => {}, 'changeSpy'); + mount( + + ); + + cy.findByLabelText('Clear') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.get('@changeSpy').should('have.been.calledOnce'); + }); + }); + }); + + describe('Logic', () => { + // TODO Gecko tests. + const regionsWithObj = regionFactory.buildList(5, { + capabilities: ['Object Storage'], + }); + const regionsWithoutObj = regionFactory.buildList(5, { + capabilities: [], + }); + const regionWithoutAvailability = regionFactory.build({ + capabilities: ['Object Storage'], + }); + const regions = [ + ...regionsWithObj, + ...regionsWithoutObj, + regionWithoutAvailability, + ]; + + it('excludes regions without availability (DC Get Well)', () => { + const mockAvailability = accountAvailabilityFactory.build({ + region: regionWithoutAvailability.id, + unavailable: ['Object Storage'], + }); + + mockGetAccountAvailability([mockAvailability]); + // TODO Remove `dcGetWell` flag override when feature flag is removed from codebase. + mount( + {}} + />, + { + dcGetWell: true, + } + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.findByText( + `${regionWithoutAvailability.label} (${regionWithoutAvailability.id})` + ) + .as('regionItem') + .scrollIntoView(); + + cy.get('@regionItem').should('be.visible'); + + cy.findByText( + `${regionWithoutAvailability.label} (${regionWithoutAvailability.id})` + ) + .closest('li') + .should('have.attr', 'data-qa-disabled-item', 'true'); + }); + + it('only lists regions with the specified capability', () => { + mount( + {}} + /> + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + regionsWithObj.forEach((region) => { + ui.autocompletePopper + .findByTitle(`${region.label} (${region.id})`) + .scrollIntoView() + .should('be.visible'); + }); + regionsWithoutObj.forEach((region) => { + ui.autocompletePopper + .findByTitle(`${region.label} (${region.id})`) + .should('not.exist'); + }); + }); + + it('lists all regions when no capability is specified', () => { + mount( + {}} + /> + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + regions.forEach((region) => { + ui.autocompletePopper + .findByTitle(`${region.label} (${region.id})`) + .scrollIntoView() + .should('be.visible'); + }); + }); + }); + + visualTests((mount) => { + describe('Accessibility checks', () => { + const selectedRegion = regionFactory.build(); + const regions = [selectedRegion, ...regionFactory.buildList(5)]; + + it('passes aXe check when menu is closed without an item selected', () => { + mount( + {}} + /> + ); + checkComponentA11y(); + }); + + it('passes aXe check when menu is closed with an item selected', () => { + mount( + {}} + /> + ); + checkComponentA11y(); + }); + + it('passes aXe check when menu is open', () => { + mount( + {}} + /> + ); + + ui.button + .findByAttribute('title', 'Open') + .should('be.visible') + .should('be.enabled') + .click(); + + checkComponentA11y(); + }); + }); + }); +}); diff --git a/packages/manager/cypress/support/component/index.html b/packages/manager/cypress/support/component/index.html new file mode 100644 index 00000000000..02e600735b2 --- /dev/null +++ b/packages/manager/cypress/support/component/index.html @@ -0,0 +1,14 @@ + + + + + + + Cloud Manager Components + + +
    +
    +
    + + diff --git a/packages/manager/cypress/support/component/setup.tsx b/packages/manager/cypress/support/component/setup.tsx new file mode 100644 index 00000000000..ac19267791c --- /dev/null +++ b/packages/manager/cypress/support/component/setup.tsx @@ -0,0 +1,84 @@ +// *********************************************************** +// This example support/component.ts is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +import * as React from 'react'; +import { mount } from 'cypress/react18'; + +import { Provider } from 'react-redux'; +import { LDProvider } from 'launchdarkly-react-client-sdk'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { queryClientFactory } from '@src/queries/base'; +import { LinodeThemeWrapper } from 'src/LinodeThemeWrapper'; +import { SnackbarProvider } from 'notistack'; +import { MemoryRouter } from 'react-router-dom'; +import { storeFactory } from 'src/store'; + +import '@testing-library/cypress/add-commands'; +import { ThemeName } from 'src/foundations/themes'; + +import 'cypress-axe'; + +// Load fonts using Vite rather than HTML ``. +import '../../../public/fonts/fonts.css'; + +/** + * Mounts a component with a Cloud Manager theme applied. + * + * @param jsx - React Component to mount. + * @param theme - Cloud Manager theme to apply. Defaults to `light`. + */ +export const mountWithTheme = ( + jsx: React.ReactNode, + theme: ThemeName = 'light', + flags: any = {} +) => { + const queryClient = queryClientFactory(); + const store = storeFactory(); + + return mount( + + + + + + {jsx} + + + + + + ); +}; + +// Augment the Cypress namespace to include type definitions for +// your custom command. +// Alternatively, can be defined in cypress/support/component.d.ts +// with a at the top of your spec. +declare global { + namespace Cypress { + interface Chainable { + mount: typeof mount; + mountWithTheme: typeof mountWithTheme; + } + } +} + +Cypress.Commands.add('mount', mount); +Cypress.Commands.add('mountWithTheme', mountWithTheme); diff --git a/packages/manager/cypress/support/index.d.ts b/packages/manager/cypress/support/index.d.ts index b6c7a2e19e1..eb5eb156ae5 100644 --- a/packages/manager/cypress/support/index.d.ts +++ b/packages/manager/cypress/support/index.d.ts @@ -1,5 +1,5 @@ +import type { mount } from 'cypress/react18'; import { Labelable } from './commands'; - import type { LinodeVisitOptions } from './login.ts'; import type { TestTag } from 'support/util/tag'; @@ -95,6 +95,11 @@ declare global { * @param state - Cypress internal state to retrieve. */ state(state?: string): any; + + /** + * Mount a React component via `cypress/react`. + */ + mount: typeof mount; } } } diff --git a/packages/manager/cypress/support/intercepts/account.ts b/packages/manager/cypress/support/intercepts/account.ts index 6e81f7b0455..a3ae28ffbbf 100644 --- a/packages/manager/cypress/support/intercepts/account.ts +++ b/packages/manager/cypress/support/intercepts/account.ts @@ -10,6 +10,7 @@ import { makeResponse } from 'support/util/response'; import type { Account, + AccountAvailability, AccountLogin, AccountMaintenance, AccountSettings, @@ -62,6 +63,23 @@ export const mockUpdateAccount = ( ); }; +/** + * Intercepts GET request to fetch account availability data and mocks response. + * + * @param accountAvailability - Account availability objects with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetAccountAvailability = ( + accountAvailability: AccountAvailability[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher('account/availability*'), + paginateResponse(accountAvailability) + ); +}; + /** * Intercepts GET request to fetch account users and mocks response. * diff --git a/packages/manager/cypress/support/util/accessibility.ts b/packages/manager/cypress/support/util/accessibility.ts new file mode 100644 index 00000000000..1dc2e8193a6 --- /dev/null +++ b/packages/manager/cypress/support/util/accessibility.ts @@ -0,0 +1,29 @@ +/** + * @file Utilities related to accessibility testing. + */ + +/** + * Performs automated Axe accessibility checks against a component. + * + * Only applicable to component tests; does not achieve anything when used + * by an integration test. + * + * @param rulesetTag - Axe ruleset tag. Defaults to WCAG 2.2 Level AA rules. + * + * @link [axe-core rule tags](https://www.deque.com/axe/core-documentation/api-documentation/#axecore-tags) + */ +export const checkComponentA11y = (rulesetTag: string = 'wcag22aa') => { + // Specify a custom aXe core path to account for monorepo package layout. + const axeCorePath = '../../node_modules/axe-core/axe.min.js'; + + // Perform checks against component only and not the surrounding HTML. + const componentContext = '[data-cy-root]'; + + cy.injectAxe({ axeCorePath }); + cy.checkA11y(componentContext, { + runOnly: { + type: 'tag', + values: [rulesetTag], + }, + }); +}; diff --git a/packages/manager/cypress/support/util/components.ts b/packages/manager/cypress/support/util/components.ts new file mode 100644 index 00000000000..0fe9bd25692 --- /dev/null +++ b/packages/manager/cypress/support/util/components.ts @@ -0,0 +1,104 @@ +/** + * @file Utilities for component testing. + */ + +import { MountReturn } from 'cypress/react18'; +import type { ThemeName } from 'src/foundations/themes'; + +/** + * Array of themes for which to test components. + */ +export const componentThemes: ThemeName[] = ['light', 'dark']; + +/** + * Default theme to use for non-visual component tests. + * + * Sorry dark theme users. + */ +// TODO Look into allowing this to be overridden via `.env`. +export const defaultTheme = 'light'; + +const capitalize = (uncapitalizedString: string): string => { + return `${uncapitalizedString[0].toUpperCase()}${uncapitalizedString.slice( + 1 + )}`; +}; + +/** + * Describes a Cypress command that can be used to mount a component. + * + * @param jsx - React node to mount. + * @param flags - Optional feature flags to apply. + */ +export type MountCommand = ( + jsx: React.ReactNode, + flags?: any +) => Cypress.Chainable; + +/** + * Describes a group of tests for a component. + * + * Passes a `mount` command to the given `callback` that can be used to + * mount any component using the default theme. + * + * @param componentName - Name of component being tested. + * @param callback - Test scope callback. + */ +export const componentTests = ( + componentName: string, + callback: (mountCommand: MountCommand) => void +) => { + const mountCommand = (jsx: React.ReactNode, flags?: any) => + cy.mountWithTheme(jsx, defaultTheme, flags); + describe(`${componentName} component tests`, () => { + callback(mountCommand); + }); +}; + +/** + * Describes a group of visual tests for a component. + * + * Tests defined inside the given `callback` will be parameterized against + * every theme. This makes `visualTests` useful for tests focused on accessibility + * and visual regression. + * + * Passes a `mount` command to the given `callback` that can be used to mount + * any component with the parameterized theme. + * + * @param callback - Test scope callback. + */ +export const visualTests = (callback: (mountCommand: MountCommand) => void) => { + describe('Visual tests', () => { + componentThemes.forEach((themeName: ThemeName) => { + const mountCommand = (jsx: React.ReactNode, flags?: any) => + cy.mountWithTheme(jsx, themeName, flags); + describe(`${capitalize(themeName)} theme`, () => { + callback(mountCommand); + }); + }); + }); +}; + +/** + * Creates a spy for the given function and assigns it an alias. + * + * @example + * const spyFn = createSpy(() => {}, 'mySpyFunction'); + * mount(); + * // ...Later, after interacting with ``. + * cy.get('@mySpyFunction').should('have.been.calledOnce'); + * + * @param fn - Function for which to create spy. + * @param alias - Alias to assign for later examination into spy. + * + * @returns The given function `fn`. + */ +// TODO Find a better place for this util. +export const createSpy = (fn: () => T, alias: string) => { + const callback = { + fn, + }; + + cy.spy(callback, 'fn').as(alias); + return callback.fn; +}; diff --git a/packages/manager/package.json b/packages/manager/package.json index 22afe983256..cbb289f2d3e 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -99,6 +99,7 @@ "cy:run": "cypress run -b chrome", "cy:e2e": "cypress run --headless -b chrome", "cy:debug": "cypress open --e2e", + "cy:component": "cypress open --component", "cy:rec-snap": "cypress run --headless -b chrome --env visualRegMode=record --spec ./cypress/integration/**/*visual*.spec.ts", "typecheck": "tsc --noEmit && tsc -p cypress --noEmit", "coverage": "vitest run --coverage && open coverage/index.html", diff --git a/packages/manager/src/components/RegionSelect/RegionOption.tsx b/packages/manager/src/components/RegionSelect/RegionOption.tsx index 718f43358dc..90a132f2d1c 100644 --- a/packages/manager/src/components/RegionSelect/RegionOption.tsx +++ b/packages/manager/src/components/RegionSelect/RegionOption.tsx @@ -70,6 +70,7 @@ export const RegionOption = ({ isRegionDisabled ? e.preventDefault() : onClick ? onClick(e) : null } aria-disabled={undefined} + data-qa-disabled-item={isRegionDisabled} className={isRegionDisabled ? `${className} Mui-disabled` : className} > <> From da1ccfbe04965330fa35b14afd02ab2990415af3 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Tue, 3 Sep 2024 14:44:24 -0400 Subject: [PATCH 09/67] upcoming: [M3-8378] - OBJ Cleanup (#10857) * upcoming: [M3-8378] - Add form logic to bucket details for bucket rate changes * Cleanup * Remove logic pertaining to bucket rate limits - far away from this being implemented * Revise comment * Update heading for bucket limit * Update test * Unskip tets * Remove false loading state --------- Co-authored-by: Jaalah Ramos --- .../BucketDetail/BucketProperties.styles.ts | 8 - .../BucketDetail/BucketProperties.tsx | 75 +++------ .../BucketLanding/BucketDetailsDrawer.tsx | 32 ++-- .../BucketRateLimitTable.test.tsx | 2 +- .../BucketLanding/BucketRateLimitTable.tsx | 152 +++++++++++------- .../OMC_CreateBucketDrawer.test.tsx | 19 +-- .../BucketLanding/OMC_CreateBucketDrawer.tsx | 31 +--- 7 files changed, 136 insertions(+), 183 deletions(-) diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.styles.ts b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.styles.ts index e44936ac4ba..96183503462 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.styles.ts +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.styles.ts @@ -24,14 +24,6 @@ export const StyledRootContainer = styled(Paper, { padding: theme.spacing(3), })); -export const StyledHelperText = styled(Typography, { - label: 'StyledHelperText', -})(({ theme }) => ({ - lineHeight: 1.5, - paddingBottom: theme.spacing(), - paddingTop: theme.spacing(), -})); - export const StyledActionsPanel = styled(ActionsPanel, { label: 'StyledActionsPanel', })(() => ({ diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.tsx index 019050b040c..6ab5971ae72 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketProperties.tsx @@ -1,18 +1,12 @@ import * as React from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; import { useHistory, useLocation } from 'react-router-dom'; -import { Link } from 'src/components/Link'; -import { Notice } from 'src/components/Notice/Notice'; -import { SupportLink } from 'src/components/SupportLink'; -import { Typography } from 'src/components/Typography'; import { getQueryParamFromQueryString } from 'src/utilities/queryParams'; import { BucketRateLimitTable } from '../BucketLanding/BucketRateLimitTable'; import { BucketBreadcrumb } from './BucketBreadcrumb'; import { StyledActionsPanel, - StyledHelperText, StyledRootContainer, StyledText, } from './BucketProperties.styles'; @@ -23,69 +17,36 @@ interface Props { bucket: ObjectStorageBucket; } -export interface UpdateBucketRateLimitPayload { - rateLimit: string; -} - export const BucketProperties = React.memo((props: Props) => { const { bucket } = props; const { endpoint_type, hostname, label } = bucket; - const form = useForm({ - defaultValues: { - rateLimit: '1', - }, - }); - - const { - formState: { errors, isDirty, isSubmitting }, - handleSubmit, - } = form; - const location = useLocation(); const history = useHistory(); const prefix = getQueryParamFromQueryString(location.search, 'prefix'); - const onSubmit = () => { - // TODO: OBJGen2 - Handle Bucket Rate Limit update logic once the endpoint for updating is available. - // The 'data' argument is expected -> data: UpdateBucketRateLimitPayload - }; - return ( - + <> - {hostname || 'Loading...'} + {hostname} - Bucket Rate Limits - - {errors.root?.message ? ( - - ) : null} - - {/* TODO: OBJGen2 - We need to handle link in upcoming PR */} - - Specifies the maximum Requests Per Second (RPS) for an Endpoint. To - increase it to High,{' '} - - . Understand bucket rate limits. - - -
    - - - + + {/* TODO: OBJGen2 - This will be handled once we receive API for bucket rates */} +
    -
    + ); }); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx index 7b774484f9e..4c8376d4a7d 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx @@ -59,6 +59,12 @@ export const BucketDetailsDrawer = React.memo( account?.capabilities ?? [] ); + const isObjectStorageGen2Enabled = isFeatureEnabledV2( + 'Object Storage Endpoint Types', + Boolean(flags.objectStorageGen2?.enabled), + account?.capabilities ?? [] + ); + // @TODO OBJGen2 - We could clean this up when OBJ Gen2 is in GA. const { data: clusters } = useObjectStorageClusters( !isObjMultiClusterEnabled @@ -74,8 +80,6 @@ export const BucketDetailsDrawer = React.memo( ); let formattedCreated; - const showBucketRateLimitTable = - endpoint_type === 'E2' || endpoint_type === 'E3'; try { if (created) { @@ -141,21 +145,15 @@ export const BucketDetailsDrawer = React.memo( )} {/* @TODO OBJ Multicluster: use region instead of cluster if isObjMultiClusterEnabled to getBucketAccess and updateBucketAccess. */} - { - <> - - Bucket Rate Limits - - {showBucketRateLimitTable ? ( - - ) : ( - - This endpoint type supports up to 750 Requests Per Second(RPS).{' '} - Understand bucket rate limits. - - )} - - } + {isObjectStorageGen2Enabled && ( + + )} {} {cluster && label && ( { +describe('BucketRateLimitTable', () => { it('should render a BucketRateLimitTable', () => { const { getAllByRole, getByText, queryByText } = renderWithTheme( diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx index 4cd414406dd..99bb79ac6c5 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketRateLimitTable.tsx @@ -1,25 +1,30 @@ import React from 'react'; -import { useController, useFormContext } from 'react-hook-form'; +import { Box } from 'src/components/Box'; import { FormControlLabel } from 'src/components/FormControlLabel'; +import { FormLabel } from 'src/components/FormLabel'; +import { Link } from 'src/components/Link'; import { Radio } from 'src/components/Radio/Radio'; +import { SupportLink } from 'src/components/SupportLink'; 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 type { UpdateBucketRateLimitPayload } from '../BucketDetail/BucketProperties'; import type { ObjectStorageEndpointTypes } from '@linode/api-v4'; +import type { TypographyProps } from 'src/components/Typography'; /** - * TODO: This component is currently using static data until + * TODO: [IMPORTANT NOTE]: This component is currently using static data until * and API endpoint is available to return rate limits for * each endpoint type. */ interface BucketRateLimitTableProps { - endpointType: ObjectStorageEndpointTypes | undefined; + endpointType?: ObjectStorageEndpointTypes; + typographyProps?: TypographyProps; } const tableHeaders = ['Limits', 'GET', 'PUT', 'LIST', 'DELETE', 'OTHER']; @@ -50,66 +55,93 @@ const tableData = ({ endpointType }: BucketRateLimitTableProps) => { export const BucketRateLimitTable = ({ endpointType, + typographyProps, }: BucketRateLimitTableProps) => { - const { control } = useFormContext(); - const { field } = useController({ - control, - name: 'rateLimit', - }); + const isGen2EndpointType = endpointType === 'E2' || endpointType === 'E3'; return ( - - - - {tableHeaders.map((header, index) => { - return ( - - {header} - - ); - })} - - - - {tableData({ endpointType }).map((row, rowIndex) => ( - - - field.onChange(row.id)} - value={row.id} + + + + Bucket Rate Limits + + + + {isGen2EndpointType ? ( + <> + Specifies the maximum Requests Per Second (RPS) for a bucket. To + increase it to High,{' '} + + .{' '} + + ) : ( + 'This endpoint type supports up to 750 Requests Per Second (RPS). ' + )} + Understand bucket rate limits. + + + {isGen2EndpointType && ( +
    + + + {tableHeaders.map((header, index) => { + return ( + + {header} + + ); + })} + + + + {tableData({ endpointType }).map((row, rowIndex) => ( + + + null} + value={row.id} + /> + } + label={row.label} /> - } - label={row.label} - /> - - {row.values.map((value, index) => { - return ( - - {value} - ); - })} - - ))} - -
    + {row.values.map((value, index) => { + return ( + + {value} + + ); + })} + + ))} + + + )} + ); }; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx index a600017fb90..dd6e664daf6 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, waitFor } from '@testing-library/react'; +import { fireEvent } from '@testing-library/react'; import * as React from 'react'; import { objectStorageEndpointsFactory } from 'src/factories'; @@ -42,7 +42,7 @@ describe('OMC_CreateBucketDrawer', () => { }); it( - 'should display the endpoint selector if endpoints exist', + 'should not display the endpoint selector if regions is not selected', server.boundary(async () => { server.use( http.get('*/v4/object-storage/endpoints', () => { @@ -58,7 +58,7 @@ describe('OMC_CreateBucketDrawer', () => { }) ); - const { getByText, queryByText } = renderWithThemeAndHookFormContext({ + const { queryByText } = renderWithThemeAndHookFormContext({ component: , options: { flags: { @@ -71,19 +71,6 @@ describe('OMC_CreateBucketDrawer', () => { expect( queryByText('Object Storage Endpoint Type') ).not.toBeInTheDocument(); - - await waitFor( - () => - expect(getByText('Object Storage Endpoint Type')).toBeInTheDocument(), - { - timeout: 2000, - } - ); - - // Additional verification after waitFor - const endpointTypeElement = getByText('Object Storage Endpoint Type'); - expect(endpointTypeElement).toBeVisible(); - expect(endpointTypeElement.tagName).toBe('LABEL'); }) ); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx index d5b4e60e33d..8efb1000a6f 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx @@ -6,7 +6,6 @@ import { Controller, useForm } from 'react-hook-form'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { Drawer } from 'src/components/Drawer'; -import { FormLabel } from 'src/components/FormLabel'; import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; @@ -236,11 +235,6 @@ export const OMC_CreateBucketDrawer = (props: Props) => { ); }, [filteredEndpointOptions, watch]); - const isGen2EndpointType = - selectedEndpointOption && - selectedEndpointOption.endpoint_type !== 'E0' && - selectedEndpointOption.endpoint_type !== 'E1'; - const { showGDPRCheckbox } = getGDPRDetails({ agreements, profile, @@ -334,7 +328,7 @@ export const OMC_CreateBucketDrawer = (props: Props) => { name="region" /> {selectedRegion?.id && } - {Boolean(endpoints) && ( + {Boolean(endpoints) && selectedRegion && ( <> ( @@ -365,24 +359,13 @@ export const OMC_CreateBucketDrawer = (props: Props) => { control={control} name="endpoint_type" /> - {selectedEndpointOption && ( - <> - - - Bucket Rate Limits - - - - {isGen2EndpointType - ? 'Specifies the maximum Requests Per Second (RPS) for a bucket. To increase it to High, open a support ticket. ' - : 'This endpoint type supports up to 750 Requests Per Second (RPS). '} - Understand bucket rate limits. - - - )} - {isGen2EndpointType && ( + {Boolean(endpoints) && selectedEndpointOption && ( )} From a8f7cd9fbeab933c9d63fcfa2fe3fd5c82c4e476 Mon Sep 17 00:00:00 2001 From: Hussain Khalil <122488130+hkhalil-akamai@users.noreply.github.com> Date: Tue, 3 Sep 2024 16:07:57 -0400 Subject: [PATCH 10/67] refactor: [M3-6852] - Remove global error interceptors (#10850) * Replace migration error interceptor with component-level error * Remove 'verification required' error interceptor * Added changeset: Remove global error interceptors * Pre-fill entity ID in support link * Revert typecheck on APIError --- .../pr-10850-tech-stories-1724879639152.md | 5 +++ .../manager/src/components/ErrorMessage.tsx | 21 ++++++---- .../GenerateFirewallDialog.tsx | 6 ++- .../manager/src/components/MigrateError.tsx | 7 +++- .../components/SupportLink/SupportLink.tsx | 2 +- .../components/SupportTicketGeneralError.tsx | 12 +++--- .../src/components/VerificationError.tsx | 24 ----------- .../DatabaseCreate/DatabaseCreate.tsx | 5 ++- .../FirewallLanding/CreateFirewallDrawer.tsx | 2 +- .../CreateCluster/CreateCluster.tsx | 2 +- .../NodePoolsDisplay/AddNodePoolDrawer.tsx | 2 +- .../features/Linodes/LinodeCreatev2/Error.tsx | 2 +- .../Linodes/LinodesCreate/LinodeCreate.tsx | 2 +- .../Linodes/MigrateLinode/MigrateLinode.tsx | 10 ++++- .../NodeBalancers/NodeBalancerCreate.tsx | 5 ++- .../src/features/Volumes/VolumeCreate.tsx | 2 +- packages/manager/src/request.tsx | 41 +------------------ .../manager/src/utilities/formikErrorUtils.ts | 16 +++----- .../src/utilities/interceptAPIError.tsx | 27 ------------ 19 files changed, 64 insertions(+), 129 deletions(-) create mode 100644 packages/manager/.changeset/pr-10850-tech-stories-1724879639152.md delete mode 100644 packages/manager/src/components/VerificationError.tsx delete mode 100644 packages/manager/src/utilities/interceptAPIError.tsx diff --git a/packages/manager/.changeset/pr-10850-tech-stories-1724879639152.md b/packages/manager/.changeset/pr-10850-tech-stories-1724879639152.md new file mode 100644 index 00000000000..3218a210a12 --- /dev/null +++ b/packages/manager/.changeset/pr-10850-tech-stories-1724879639152.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Remove global error interceptors ([#10850](https://github.com/linode/manager/pull/10850)) diff --git a/packages/manager/src/components/ErrorMessage.tsx b/packages/manager/src/components/ErrorMessage.tsx index 68917fb0fd3..8bb3871e750 100644 --- a/packages/manager/src/components/ErrorMessage.tsx +++ b/packages/manager/src/components/ErrorMessage.tsx @@ -1,29 +1,32 @@ import React from 'react'; +import { MigrateError } from './MigrateError'; import { SupportTicketGeneralError } from './SupportTicketGeneralError'; import { Typography } from './Typography'; -import type { - EntityType, - FormPayloadValues, -} from 'src/features/Support/SupportTickets/SupportTicketDialog'; +import type { EntityForTicketDetails } from './SupportLink/SupportLink'; +import type { FormPayloadValues } from 'src/features/Support/SupportTickets/SupportTicketDialog'; interface Props { - entityType: EntityType; + entity?: EntityForTicketDetails; formPayloadValues?: FormPayloadValues; message: string; } +export const migrationsDisabledRegex = /migrations are currently disabled/i; export const supportTextRegex = /(open a support ticket|contact Support)/i; export const ErrorMessage = (props: Props) => { - const { entityType, formPayloadValues, message } = props; - const isSupportTicketError = supportTextRegex.test(message); + const { entity, formPayloadValues, message } = props; - if (isSupportTicketError) { + if (migrationsDisabledRegex.test(message)) { + return ; + } + + if (supportTextRegex.test(message)) { return ( diff --git a/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx b/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx index add07267945..e102a493d0c 100644 --- a/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx +++ b/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx @@ -5,8 +5,10 @@ import { replaceNewlinesWithLineBreaks } from 'src/utilities/replaceNewlinesWith import { Button } from '../Button/Button'; import { Dialog } from '../Dialog/Dialog'; +import { ErrorMessage } from '../ErrorMessage'; import { LinearProgress } from '../LinearProgress'; import { Link } from '../Link'; +import { Notice } from '../Notice/Notice'; import { Stack } from '../Stack'; import { Typography } from '../Typography'; import { useCreateFirewallFromTemplate } from './useCreateFirewallFromTemplate'; @@ -209,7 +211,9 @@ const ErrorDialogContent = ( return ( An error occurred - {error} + + + diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx index f299265ee2d..5989e725b93 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/MaintenanceWindow.tsx @@ -64,11 +64,12 @@ const useStyles = makeStyles()((theme: Theme) => ({ interface Props { database: Database; + disabled?: boolean; timezone?: string; } export const MaintenanceWindow = (props: Props) => { - const { database, timezone } = props; + const { database, disabled, timezone } = props; const [maintenanceUpdateError, setMaintenanceUpdateError] = React.useState< APIError[] @@ -217,6 +218,7 @@ export const MaintenanceWindow = (props: Props) => { value={daySelectionMap.find( (thisOption) => thisOption.value === values.day_of_week )} + disabled={disabled} errorText={touched.day_of_week ? errors.day_of_week : undefined} isClearable={false} label="Day of Week" @@ -248,6 +250,7 @@ export const MaintenanceWindow = (props: Props) => { value={hourSelectionMap.find( (thisOption) => thisOption.value === values.hour_of_day )} + disabled={disabled} isClearable={false} label="Time of Day (UTC)" menuPlacement="top" @@ -296,6 +299,7 @@ export const MaintenanceWindow = (props: Props) => { ); } }} + disabled={disabled} > { buttonType="primary" className={classes.sectionButton} compactX - disabled={!formTouched || isSubmitting} + disabled={!formTouched || isSubmitting || disabled} loading={isSubmitting} + title="Save Changes" type="submit" > Save Changes diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx index 691f76f5968..10cd81ba3be 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx @@ -13,10 +13,11 @@ import ConnectionDetails from './DatabaseSummaryConnectionDetails'; interface Props { database: Database; + disabled?: boolean; } export const DatabaseSummary: React.FC = (props) => { - const { database } = props; + const { database, disabled } = props; const description = ( <> @@ -46,7 +47,11 @@ export const DatabaseSummary: React.FC = (props) => { - + ); }; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx index 1d17c5312bb..fb48868cbb0 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx @@ -7,12 +7,14 @@ import { CircleProgress } from 'src/components/CircleProgress'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; +import { Notice } from 'src/components/Notice/Notice'; 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 { useEditableLabelState } from 'src/hooks/useEditableLabelState'; import { useFlags } from 'src/hooks/useFlags'; +import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useDatabaseMutation, useDatabaseQuery, @@ -45,6 +47,12 @@ export const DatabaseDetail = () => { const { mutateAsync: updateDatabase } = useDatabaseMutation(engine, id); + const isDatabasesGrantReadOnly = useIsResourceRestricted({ + grantLevel: 'read_only', + grantType: 'database', + id, + }); + const { editableLabelError, resetEditableLabel, @@ -150,24 +158,44 @@ export const DatabaseDetail = () => { }, pathname: location.pathname, }} + disabledBreadcrumbEditButton={isDatabasesGrantReadOnly} title={database.label} /> + {isDatabasesGrantReadOnly && ( + + )} + - + - + {flags.databaseResize ? ( - + ) : null} - + diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx index 2d8c4cde06b..7060c149b6b 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseEmptyState.tsx @@ -3,6 +3,8 @@ import { useHistory } from 'react-router-dom'; import DatabaseIcon from 'src/assets/icons/entityIcons/database.svg'; 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 { @@ -15,11 +17,16 @@ import { export const DatabaseEmptyState = () => { const { push } = useHistory(); + const isRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_databases', + }); + return ( { sendEvent({ action: 'Click:button', @@ -28,6 +35,11 @@ export const DatabaseEmptyState = () => { }); push('/databases/create'); }, + tooltipText: getRestrictedResourceText({ + action: 'create', + isSingular: false, + resourceType: 'Databases', + }), }, ]} gettingStartedGuidesData={gettingStartedGuides} diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx index e88a861e1f6..d23e132ec4b 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx @@ -16,6 +16,18 @@ import { import DatabaseLanding from './DatabaseLanding'; import DatabaseRow from './DatabaseRow'; +const queryMocks = vi.hoisted(() => ({ + useProfile: vi.fn().mockReturnValue({ data: { restricted: false } }), +})); + +vi.mock('src/queries/profile/profile', async () => { + const actual = await vi.importActual('src/queries/profile/profile'); + return { + ...actual, + useProfile: queryMocks.useProfile, + }; +}); + beforeAll(() => mockMatchMedia()); const loadingTestId = 'circle-progress'; @@ -98,3 +110,37 @@ describe('Database Table', () => { ).toBeInTheDocument(); }); }); + +describe('Database Landing', () => { + it('should have the "Create Database Cluster" button disabled for restricted users', async () => { + queryMocks.useProfile.mockReturnValue({ data: { restricted: true } }); + + const { container, getByTestId } = renderWithTheme(); + + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + const createClusterButton = container.querySelector('button'); + + expect(createClusterButton).toBeInTheDocument(); + expect(createClusterButton).toHaveTextContent('Create Database Cluster'); + expect(createClusterButton).toBeDisabled(); + }); + + it('should have the "Create Database Cluster" button enabled for users with full access', async () => { + queryMocks.useProfile.mockReturnValue({ data: { restricted: false } }); + + const { container, getByTestId } = renderWithTheme(); + + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + const createClusterButton = container.querySelector('button'); + + expect(createClusterButton).toBeInTheDocument(); + expect(createClusterButton).toHaveTextContent('Create Database Cluster'); + expect(createClusterButton).not.toBeDisabled(); + }); +}); diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx index 0a3b16606fa..99363528efe 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx @@ -1,4 +1,3 @@ -import { DatabaseInstance } from '@linode/api-v4/lib/databases'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; @@ -15,17 +14,25 @@ import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; +import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; import { useDatabasesQuery } from 'src/queries/databases/databases'; import { useInProgressEvents } from 'src/queries/events/events'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + import { DatabaseEmptyState } from './DatabaseEmptyState'; import { DatabaseRow } from './DatabaseRow'; +import type { DatabaseInstance } from '@linode/api-v4/lib/databases'; +import { getRestrictedResourceText } from 'src/features/Account/utils'; + const preferenceKey = 'databases'; const DatabaseLanding = () => { const history = useHistory(); const pagination = usePagination(1, preferenceKey); + const isRestricted = useRestrictedGlobalGrantCheck({ + globalGrantType: 'add_databases', + }); const { data: events } = useInProgressEvents(); @@ -71,7 +78,15 @@ const DatabaseLanding = () => { return ( history.push('/databases/create')} title="Database Clusters" From 213a565a55d44ef478630d8197e29048d26486f2 Mon Sep 17 00:00:00 2001 From: jdamore-linode <97627410+jdamore-linode@users.noreply.github.com> Date: Thu, 5 Sep 2024 15:37:58 -0400 Subject: [PATCH 21/67] test: [M3-8471] - Allow Cypress region selection to work with Gecko options improvements (#10888) * Allow Cypress region selection to work with Gecko options improvements * Add changeset --- .../manager/.changeset/pr-10888-tests-1725482771826.md | 5 +++++ packages/manager/cypress/support/ui/autocomplete.ts | 10 +++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-10888-tests-1725482771826.md diff --git a/packages/manager/.changeset/pr-10888-tests-1725482771826.md b/packages/manager/.changeset/pr-10888-tests-1725482771826.md new file mode 100644 index 00000000000..15c85ccfbca --- /dev/null +++ b/packages/manager/.changeset/pr-10888-tests-1725482771826.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Update region selection helpers to account for upcoming Gecko improvements ([#10888](https://github.com/linode/manager/pull/10888)) diff --git a/packages/manager/cypress/support/ui/autocomplete.ts b/packages/manager/cypress/support/ui/autocomplete.ts index 86356ab3d88..2be652878ce 100644 --- a/packages/manager/cypress/support/ui/autocomplete.ts +++ b/packages/manager/cypress/support/ui/autocomplete.ts @@ -33,7 +33,7 @@ export const autocompletePopper = { * Finds an item within an autocomplete popper that has the given title. */ findByTitle: ( - title: string, + title: string | RegExp, options?: SelectorMatcherOptions ): Cypress.Chainable => { return ( @@ -90,7 +90,9 @@ export const regionSelect = { */ findItemByRegionId: (regionId: string, searchRegions?: Region[]) => { const region = getRegionById(regionId, searchRegions); - return autocompletePopper.findByTitle(`${region.label} (${region.id})`); + return autocompletePopper.findByTitle( + new RegExp(`${region.label}\\s?(\(${region.id}\))?`) + ); }, /** @@ -105,6 +107,8 @@ export const regionSelect = { */ findItemByRegionLabel: (regionLabel: string, searchRegions?: Region[]) => { const region = getRegionByLabel(regionLabel, searchRegions); - return autocompletePopper.findByTitle(`${region.label} (${region.id})`); + return autocompletePopper.findByTitle( + new RegExp(`${region.label}\\s?(\(${region.id}\))?`) + ); }, }; From ad03a2350f8ff8fd7dbe35f40daa99f528fa79c5 Mon Sep 17 00:00:00 2001 From: corya-akamai <136115382+corya-akamai@users.noreply.github.com> Date: Thu, 5 Sep 2024 19:49:30 -0400 Subject: [PATCH 22/67] feat: [UIE-8006] - DBaaS 2.0 Create (#10872) * feat: [UIE-8054] - DBaaS enhancements 1 * feat: [UIE-8006] - DBaaS create flow enhancements * fix: [UIE-8084] - DBaaS V2 beta flag * Review comments: better types. * changeset * Review comments: better types and test setup * fix failing e2e test create-database --- packages/api-v4/src/databases/types.ts | 12 +- ...r-10872-upcoming-features-1725396256315.md | 5 + .../components/PrimaryNav/PrimaryNav.test.tsx | 109 +++++++++++++++++- .../src/components/PrimaryNav/PrimaryNav.tsx | 6 +- .../components/TabbedPanel/TabbedPanel.tsx | 4 +- packages/manager/src/factories/account.ts | 2 +- packages/manager/src/factories/databases.ts | 10 +- .../DatabaseCreate/DatabaseCreate.test.tsx | 81 ++++++++++++- .../DatabaseCreate/DatabaseCreate.tsx | 106 ++++++++++++----- .../src/features/Databases/utilities.test.ts | 2 + .../src/features/Databases/utilities.ts | 1 + .../components/PlansPanel/PlansPanel.tsx | 3 + 12 files changed, 288 insertions(+), 53 deletions(-) create mode 100644 packages/manager/.changeset/pr-10872-upcoming-features-1725396256315.md diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 3221a8c61b6..933f1978c86 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -28,15 +28,15 @@ export interface DatabaseEngine { } export type DatabaseStatus = + | 'active' + | 'degraded' + | 'failed' | 'provisioning' | 'resizing' - | 'active' - | 'suspending' - | 'suspended' - | 'resuming' | 'restoring' - | 'failed' - | 'degraded'; + | 'resuming' + | 'suspended' + | 'suspending'; export type DatabaseBackupType = 'snapshot' | 'auto'; diff --git a/packages/manager/.changeset/pr-10872-upcoming-features-1725396256315.md b/packages/manager/.changeset/pr-10872-upcoming-features-1725396256315.md new file mode 100644 index 00000000000..d1b95b56f16 --- /dev/null +++ b/packages/manager/.changeset/pr-10872-upcoming-features-1725396256315.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +DBaaS V2 create enhancements ([#10872](https://github.com/linode/manager/pull/10872)) diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx index f210c6e1b89..565b4c33451 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx @@ -7,6 +7,8 @@ import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; import PrimaryNav from './PrimaryNav'; +import type { Flags } from 'src/featureFlags'; + const props = { closeMenu: vi.fn(), isCollapsed: false, @@ -54,7 +56,7 @@ describe('PrimaryNav', () => { expect(getByTestId(queryString).getAttribute('aria-current')).toBe('false'); }); - it('should show Databases menu item if the user has the account capability', async () => { + it('should show Databases menu item if the user has the account capability V1', async () => { const account = accountFactory.build({ capabilities: ['Managed Databases'], }); @@ -65,7 +67,110 @@ describe('PrimaryNav', () => { }) ); - const { findByText } = renderWithTheme(); + const flags: Partial = { + dbaasV2: { + beta: true, + enabled: true, + }, + }; + + const { findByText, queryByTestId } = renderWithTheme( + , + { + flags, + } + ); + + const databaseNavItem = await findByText('Databases'); + + expect(databaseNavItem).toBeVisible(); + expect(queryByTestId('betaChip')).toBeNull(); + }); + + it('should show Databases menu item if the user has the account capability V2 Beta', async () => { + const account = accountFactory.build({ + capabilities: ['Managed Databases V2'], + }); + + server.use( + http.get('*/account', () => { + return HttpResponse.json(account); + }) + ); + + const flags: Partial = { + dbaasV2: { + beta: true, + enabled: true, + }, + }; + + const { findByTestId, findByText } = renderWithTheme( + , + { + flags, + } + ); + + const databaseNavItem = await findByText('Databases'); + const betaChip = await findByTestId('betaChip'); + + expect(databaseNavItem).toBeVisible(); + expect(betaChip).toBeVisible(); + }); + + it('should show Databases menu item if the user has the account capability V2', async () => { + const account = accountFactory.build({ + capabilities: ['Managed Databases V2'], + }); + + server.use( + http.get('*/account', () => { + return HttpResponse.json(account); + }) + ); + + const flags: Partial = { + dbaasV2: { + beta: false, + enabled: true, + }, + }; + + const { findByText, queryByTestId } = renderWithTheme( + , + { + flags, + } + ); + + const databaseNavItem = await findByText('Databases'); + + expect(databaseNavItem).toBeVisible(); + expect(queryByTestId('betaChip')).toBeNull(); + }); + + it('should show Databases menu item if the user has the account capability V2', async () => { + const account = accountFactory.build({ + capabilities: ['Managed Databases V2'], + }); + + server.use( + http.get('*/account', () => { + return HttpResponse.json(account); + }) + ); + + const flags: Partial = { + dbaasV2: { + beta: true, + enabled: true, + }, + }; + + const { findByText } = renderWithTheme(, { + flags, + }); const databaseNavItem = await findByText('Databases'); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index e28aca7b644..cffcd3c848f 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -108,7 +108,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { const { isACLPEnabled } = useIsACLPEnabled(); const { isPlacementGroupsEnabled } = useIsPlacementGroupsEnabled(); - const { isDatabasesEnabled } = useIsDatabasesEnabled(); + const { isDatabasesEnabled, isDatabasesV2Beta } = useIsDatabasesEnabled(); const prefetchMarketplace = () => { if (!enableMarketplacePrefetch) { @@ -187,7 +187,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => { hide: !isDatabasesEnabled, href: '/databases', icon: , - isBeta: flags.dbaasV2?.beta, + isBeta: isDatabasesV2Beta, }, { activeLinks: ['/kubernetes/create'], @@ -247,9 +247,9 @@ export const PrimaryNav = (props: PrimaryNavProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps [ isDatabasesEnabled, + isDatabasesV2Beta, isManaged, allowMarketplacePrefetch, - flags.dbaasV2, isPlacementGroupsEnabled, flags.placementGroups, isACLPEnabled, diff --git a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx index c748d7958ee..f2f63505c20 100644 --- a/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx +++ b/packages/manager/src/components/TabbedPanel/TabbedPanel.tsx @@ -27,7 +27,7 @@ interface TabbedPanelProps { copy?: string; docsLink?: JSX.Element; error?: JSX.Element | string; - handleTabChange?: () => void; + handleTabChange?: (index: number) => void; header: string; initTab?: number; innerClass?: string; @@ -66,7 +66,7 @@ const TabbedPanel = React.memo((props: TabbedPanelProps) => { const tabChangeHandler = (index: number) => { setTabIndex(index); if (handleTabChange) { - handleTabChange(); + handleTabChange(index); } }; diff --git a/packages/manager/src/factories/account.ts b/packages/manager/src/factories/account.ts index 241deeea45f..eff241f37d5 100644 --- a/packages/manager/src/factories/account.ts +++ b/packages/manager/src/factories/account.ts @@ -45,7 +45,7 @@ export const accountFactory = Factory.Sync.makeFactory({ 'Linodes', 'LKE HA Control Planes', 'Machine Images', - 'Managed Databases V2', + 'Managed Databases', 'NodeBalancers', 'Object Storage Access Key Regions', 'Object Storage Endpoint Types', diff --git a/packages/manager/src/factories/databases.ts b/packages/manager/src/factories/databases.ts index 87ee964126f..657a262d804 100644 --- a/packages/manager/src/factories/databases.ts +++ b/packages/manager/src/factories/databases.ts @@ -16,14 +16,16 @@ import type { PostgresReplicationType, } from '@linode/api-v4/lib/databases/types'; -// These are not all of the possible statuses, but these are some common ones. export const possibleStatuses: DatabaseStatus[] = [ - 'provisioning', 'active', - 'failed', 'degraded', - 'restoring', + 'failed', + 'provisioning', 'resizing', + 'restoring', + 'resuming', + 'suspended', + 'suspending', ]; export const possibleMySQLReplicationTypes: MySQLReplicationType[] = [ diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx index 580d3b78eca..0faa8c693c9 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx @@ -3,11 +3,10 @@ import { createMemoryHistory } from 'history'; import * as React from 'react'; import { Router } from 'react-router-dom'; -import { databaseTypeFactory } from 'src/factories'; +import { accountFactory, databaseTypeFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; -import { mockMatchMedia } from 'src/utilities/testHelpers'; +import { mockMatchMedia, renderWithTheme } from 'src/utilities/testHelpers'; import DatabaseCreate from './DatabaseCreate'; @@ -79,4 +78,80 @@ describe('Database Create', () => { expect(nodeRadioBtns).toHaveTextContent('$60/month $0.09/hr'); expect(nodeRadioBtns).toHaveTextContent('$140/month $0.21/hr'); }); + + it('should display the correct nodes for account with Managed Databases', async () => { + server.use( + http.get('*/account', () => { + const account = accountFactory.build({ + capabilities: ['Managed Databases'], + }); + return HttpResponse.json(account); + }) + ); + + // Mock route history so the Plan Selection table displays prices without requiring a region in the DB Create flow. + const history = createMemoryHistory(); + history.push('databases/create'); + + const { getAllByText, getByTestId } = renderWithTheme( + + + + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // default to $0 if no plan is selected + const nodeRadioBtns = getByTestId('database-nodes'); + expect(nodeRadioBtns).toHaveTextContent('$0/month $0/hr'); + + // update node pricing if a plan is selected + const radioBtn = getAllByText('Nanode 1 GB')[0]; + fireEvent.click(radioBtn); + expect(nodeRadioBtns).toHaveTextContent('$60/month $0.09/hr'); + expect(nodeRadioBtns).not.toHaveTextContent('$100/month $0.15/hr'); + expect(nodeRadioBtns).toHaveTextContent('$140/month $0.21/hr'); + }); + + it('should display the correct nodes for account with Managed Databases V2', async () => { + server.use( + http.get('*/account', () => { + const account = accountFactory.build({ + capabilities: ['Managed Databases V2'], + }); + return HttpResponse.json(account); + }) + ); + + // Mock route history so the Plan Selection table displays prices without requiring a region in the DB Create flow. + const history = createMemoryHistory(); + history.push('databases/create'); + + const flags = { + dbaasV2: { + beta: true, + enabled: true, + }, + }; + + const { getAllByText, getByTestId } = renderWithTheme( + + + , + { flags } + ); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + // default to $0 if no plan is selected + const nodeRadioBtns = getByTestId('database-nodes'); + expect(nodeRadioBtns).toHaveTextContent('$0/month $0/hr'); + + // update node pricing if a plan is selected + const radioBtn = getAllByText('Nanode 1 GB')[0]; + fireEvent.click(radioBtn); + expect(nodeRadioBtns).toHaveTextContent('$60/month $0.09/hr'); + expect(nodeRadioBtns).toHaveTextContent('$100/month $0.15/hr'); + expect(nodeRadioBtns).toHaveTextContent('$140/month $0.21/hr'); + }); }); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx index 3d11db95383..20fadc053c9 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -32,10 +32,11 @@ import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; import { PlansPanel } from 'src/features/components/PlansPanel/PlansPanel'; import { EngineOption } from 'src/features/Databases/DatabaseCreate/EngineOption'; +import { DatabaseLogo } from 'src/features/Databases/DatabaseLanding/DatabaseLogo'; import { databaseEngineMap } from 'src/features/Databases/DatabaseLanding/DatabaseRow'; +import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import { enforceIPMasks } from 'src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils'; import { typeLabelDetails } from 'src/features/Linodes/presentation'; -import { useFlags } from 'src/hooks/useFlags'; import { useCreateDatabaseMutation, useDatabaseEnginesQuery, @@ -63,6 +64,9 @@ import type { Item } from 'src/components/EnhancedSelect/Select'; import type { PlanSelectionType } from 'src/features/components/PlansPanel/types'; import type { ExtendedIP } from 'src/utilities/ipUtils'; +const V1 = 'Managed Databases'; +const V2 = `Managed Databases V2`; + const useStyles = makeStyles()((theme: Theme) => ({ btnCtn: { alignItems: 'center', @@ -187,6 +191,7 @@ const getEngineOptions = (engines: DatabaseEngine[]) => { }; interface NodePricing { + double: DatabasePriceObject | undefined; multi: DatabasePriceObject | undefined; single: DatabasePriceObject | undefined; } @@ -194,7 +199,6 @@ interface NodePricing { const DatabaseCreate = () => { const { classes } = useStyles(); const history = useHistory(); - const flags = useFlags(); const { data: regionsData, @@ -214,12 +218,15 @@ const DatabaseCreate = () => { isLoading: typesLoading, } = useDatabaseTypesQuery(); + const { isDatabasesV2Beta, isDatabasesV2Enabled } = useIsDatabasesEnabled(); + const formRef = React.useRef(null); const { mutateAsync: createDatabase } = useCreateDatabaseMutation(); const [nodePricing, setNodePricing] = React.useState(); const [createError, setCreateError] = React.useState(); const [ipErrorsFromAPI, setIPErrorsFromAPI] = React.useState(); + const [selectedTab, setSelectedTab] = React.useState(0); const engineOptions = React.useMemo(() => { if (!engines) { @@ -356,33 +363,46 @@ const DatabaseCreate = () => { }); }, [dbtypes, selectedEngine]); - const labelToolTip = ( -
    - Label must: -
      -
    • Begin with an alpha character
    • -
    • Contain only alpha characters or single hyphens
    • -
    • Be between 3 - 32 characters
    • -
    -
    - ); + const nodeOptions = React.useMemo(() => { + const hasDedicated = displayTypes.some( + (type) => type.class === 'dedicated' + ); - const nodeOptions = [ - { - label: ( - - 1 Node {` `} -
    - - {`$${nodePricing?.single?.monthly || 0}/month $${ - nodePricing?.single?.hourly || 0 - }/hr`} - -
    - ), - value: 1, - }, - { + const options = [ + { + label: ( + + 1 Node {` `} +
    + + {`$${nodePricing?.single?.monthly || 0}/month $${ + nodePricing?.single?.hourly || 0 + }/hr`} + +
    + ), + value: 1, + }, + ]; + + if (hasDedicated && selectedTab === 0 && isDatabasesV2Enabled) { + options.push({ + label: ( + + 2 Nodes - High Availability +
    + + {`$${nodePricing?.double?.monthly || 0}/month $${ + nodePricing?.double?.hourly || 0 + }/hr`} + +
    + ), + value: 2, + }); + } + + options.push({ label: ( 3 Nodes - High Availability (recommended) @@ -395,8 +415,25 @@ const DatabaseCreate = () => { ), value: 3, - }, - ]; + }); + + return options; + }, [selectedTab, nodePricing, displayTypes, isDatabasesV2Enabled]); + + const labelToolTip = ( +
    + Label must: +
      +
    • Begin with an alpha character
    • +
    • Contain only alpha characters or single hyphens
    • +
    • Be between 3 - 32 characters
    • +
    +
    + ); + + const handleTabChange = (index: number) => { + setSelectedTab(index); + }; React.useEffect(() => { if (values.type.length === 0 || !dbtypes) { @@ -411,6 +448,9 @@ const DatabaseCreate = () => { const engineType = values.engine.split('/')[0] as Engine; setNodePricing({ + double: type.engines[engineType].find( + (cluster: DatabaseClusterSizeObject) => cluster.quantity === 2 + )?.price, multi: type.engines[engineType].find( (cluster: DatabaseClusterSizeObject) => cluster.quantity === 3 )?.price, @@ -453,7 +493,7 @@ const DatabaseCreate = () => { }, ], labelOptions: { - suffixComponent: flags.dbaasV2?.beta ? ( + suffixComponent: isDatabasesV2Beta ? ( ) : null, }, @@ -504,7 +544,7 @@ const DatabaseCreate = () => { setFieldValue('region', region.id)} @@ -522,6 +562,7 @@ const DatabaseCreate = () => { className={classes.selectPlanPanel} data-qa-select-plan error={errors.type} + handleTabChange={handleTabChange} header="Choose a Plan" isCreate regionsData={regionsData} @@ -623,6 +664,7 @@ const DatabaseCreate = () => { Create Database Cluster + {isDatabasesV2Enabled && } ); }; diff --git a/packages/manager/src/features/Databases/utilities.test.ts b/packages/manager/src/features/Databases/utilities.test.ts index dd981780f18..51c367ac709 100644 --- a/packages/manager/src/features/Databases/utilities.test.ts +++ b/packages/manager/src/features/Databases/utilities.test.ts @@ -29,6 +29,7 @@ describe('useIsDatabasesEnabled', () => { await waitFor(() => { expect(result.current.isDatabasesEnabled).toBe(true); expect(result.current.isDatabasesV1Enabled).toBe(true); + expect(result.current.isDatabasesV2Beta).toBe(false); expect(result.current.isDatabasesV2Enabled).toBe(false); }); }); @@ -54,6 +55,7 @@ describe('useIsDatabasesEnabled', () => { await waitFor(() => { expect(result.current.isDatabasesEnabled).toBe(true); expect(result.current.isDatabasesV1Enabled).toBe(false); + expect(result.current.isDatabasesV2Beta).toBe(true); expect(result.current.isDatabasesV2Enabled).toBe(true); }); }); diff --git a/packages/manager/src/features/Databases/utilities.ts b/packages/manager/src/features/Databases/utilities.ts index 73e64f1f8e3..8e1e203235c 100644 --- a/packages/manager/src/features/Databases/utilities.ts +++ b/packages/manager/src/features/Databases/utilities.ts @@ -39,6 +39,7 @@ export const useIsDatabasesEnabled = () => { return { isDatabasesEnabled: isDatabasesV1Enabled || isDatabasesV2Enabled, isDatabasesV1Enabled, + isDatabasesV2Beta: isDatabasesV2Enabled && flags.dbaasV2?.beta, isDatabasesV2Enabled, }; } diff --git a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx index f1e8f4742bc..dc65e613572 100644 --- a/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx +++ b/packages/manager/src/features/components/PlansPanel/PlansPanel.tsx @@ -35,6 +35,7 @@ export interface PlansPanelProps { disabledTabs?: string[]; docsLink?: JSX.Element; error?: string; + handleTabChange?: (index: number) => void; header?: string; isCreate?: boolean; linodeID?: number | undefined; @@ -66,6 +67,7 @@ export const PlansPanel = (props: PlansPanelProps) => { disabledSmallerPlans, docsLink, error, + handleTabChange, header, isCreate, linodeID, @@ -223,6 +225,7 @@ export const PlansPanel = (props: PlansPanelProps) => { data-qa-select-plan docsLink={docsLink} error={error} + handleTabChange={handleTabChange} header={header || 'Linode Plan'} initTab={initialTab >= 0 ? initialTab : 0} innerClass={props.tabbedPanelInnerClass} From 1e920549e2c7f41670201a53956566b44749b6cb Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Thu, 5 Sep 2024 21:20:44 -0400 Subject: [PATCH 23/67] refactor: [M3-8496] - Remove Placement Group flag and underlying logic (#10877) * Remove flag and undelying logic & update tests * Added changeset: Delete Placement Groups feature flag conditional rendering * feedback @mjac0bs @carillo-erik --- .../pr-10877-tech-stories-1725397280235.md | 5 ++ .../e2e/core/linodes/plan-selection.spec.ts | 13 ---- ...reate-linode-with-placement-groups.spec.ts | 4 -- .../create-placement-groups.spec.ts | 14 ----- .../delete-placement-groups.spec.ts | 15 ----- .../placement-groups-landing-page.spec.ts | 15 ----- ...placement-groups-linode-assignment.spec.ts | 15 ----- .../placement-groups-navigation.spec.ts | 59 ++++++------------- .../update-placement-group-label.spec.ts | 13 ---- .../src/components/PrimaryNav/PrimaryNav.tsx | 2 - .../manager/src/dev-tools/FeatureFlagTool.tsx | 1 - packages/manager/src/featureFlags.ts | 1 - .../LinodeCreatev2/Details/Details.test.tsx | 18 +----- .../MigrateLinode/ConfigureForm.test.tsx | 5 +- .../features/PlacementGroups/utils.test.ts | 24 +------- .../src/features/PlacementGroups/utils.ts | 6 +- 16 files changed, 28 insertions(+), 182 deletions(-) create mode 100644 packages/manager/.changeset/pr-10877-tech-stories-1725397280235.md diff --git a/packages/manager/.changeset/pr-10877-tech-stories-1725397280235.md b/packages/manager/.changeset/pr-10877-tech-stories-1725397280235.md new file mode 100644 index 00000000000..ed048a78f0b --- /dev/null +++ b/packages/manager/.changeset/pr-10877-tech-stories-1725397280235.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Delete Placement Groups feature flag conditional rendering ([#10877](https://github.com/linode/manager/pull/10877)) 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 5a6480c18ec..b4f2d50bca8 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -12,13 +12,6 @@ import { mockGetRegionAvailability, } from 'support/intercepts/regions'; import { mockGetLinodeTypes } from 'support/intercepts/linodes'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; - -import type { Flags } from 'src/featureFlags'; const mockRegions = [ regionFactory.build({ @@ -363,12 +356,6 @@ describe('displays specific linode plans for GPU', () => { mockGetRegionAvailability(mockRegions[0].id, mockRegionAvailability).as( 'getRegionAvailability' ); - mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ - planDivider: true, - }), - }); - mockGetFeatureFlagClientstream(); }); it('Should render divided tables when GPU divider enabled', () => { 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 index d57fec9b84f..b1573679bdc 100644 --- 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 @@ -55,10 +55,6 @@ describe('Linode create flow with Placement Group', () => { mockGetRegions(mockRegions).as('getRegions'); // TODO Remove feature flag mocks when `placementGroups` flag is retired. mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ - beta: true, - enabled: true, - }), linodeCreateRefactor: makeFeatureFlagData( false ), 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 5a833e72296..39069134862 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 @@ -1,14 +1,8 @@ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { mockGetAccount } from 'support/intercepts/account'; import { accountFactory, placementGroupFactory } from 'src/factories'; import { regionFactory } from 'src/factories'; import { ui } from 'support/ui/'; -import type { Flags } from 'src/featureFlags'; import { mockGetRegions } from 'support/intercepts/regions'; import { mockCreatePlacementGroup, @@ -23,14 +17,6 @@ const mockAccount = accountFactory.build(); describe('Placement Group create flow', () => { beforeEach(() => { - // TODO Remove feature flag mocks when `placementGroups` flag is retired. - mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ - beta: true, - enabled: true, - }), - }); - mockGetFeatureFlagClientstream(); mockGetAccount(mockAccount); }); 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 cae1fb1f4ef..b08c39cb67b 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 @@ -2,12 +2,6 @@ * @file Cypress integration tests for VM Placement Groups deletion flows. */ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; - import { mockGetAccount } from 'support/intercepts/account'; import { mockDeletePlacementGroup, @@ -22,7 +16,6 @@ import { placementGroupFactory, } from 'src/factories'; import { headers as emptyStatePageHeaders } from 'src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLandingEmptyStateData'; -import type { Flags } from 'src/featureFlags'; import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { ui } from 'support/ui'; @@ -48,14 +41,6 @@ const PlacementGroupErrorMessage = 'An unknown error has occurred.'; describe('Placement Group deletion', () => { beforeEach(() => { - // TODO Remove feature flag mocks when `placementGroups` flag is retired. - mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ - beta: true, - enabled: true, - }), - }); - mockGetFeatureFlagClientstream(); mockGetAccount(mockAccount); }); diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts index 1b2fb9f1e7c..244f629f834 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-landing-page.spec.ts @@ -1,8 +1,3 @@ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { mockGetPlacementGroups } from 'support/intercepts/placement-groups'; import { ui } from 'support/ui'; import { @@ -15,20 +10,10 @@ import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { mockGetLinodes } from 'support/intercepts/linodes'; -import type { Flags } from 'src/featureFlags'; - const mockAccount = accountFactory.build(); describe('VM Placement landing page', () => { - // Mock the VM Placement Groups feature flag to be enabled for each test in this block. beforeEach(() => { - mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ - beta: true, - enabled: true, - }), - }); - mockGetFeatureFlagClientstream(); mockGetAccount(mockAccount).as('getAccount'); }); diff --git a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts index 882febd4cc1..90890095375 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-linode-assignment.spec.ts @@ -5,10 +5,6 @@ import { regionFactory, } from 'src/factories'; import { mockGetAccount } from 'support/intercepts/account'; -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; import { mockGetLinodeDetails, mockGetLinodes, @@ -24,12 +20,10 @@ import { import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { buildArray } from 'support/util/arrays'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import type { Linode } from '@linode/api-v4'; -import type { Flags } from 'src/featureFlags'; const mockAccount = accountFactory.build(); @@ -52,16 +46,7 @@ const mockRegions = regionFactory.buildList(10, { }); describe('Placement Groups Linode assignment', () => { - // Mock the VM Placement Groups feature flag to be enabled for each test in this block. - // TODO Remove these mocks when `placementGroups` feature flag is retired. beforeEach(() => { - mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ - beta: true, - enabled: true, - }), - }); - mockGetFeatureFlagClientstream(); mockGetAccount(mockAccount).as('getAccount'); }); 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 index e405cf03829..35c5473b9a5 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/placement-groups-navigation.spec.ts @@ -2,7 +2,6 @@ * @file Integration tests for Placement Groups navigation. */ -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetAccount } from 'support/intercepts/account'; import { accountFactory } from 'src/factories'; import { ui } from 'support/ui'; @@ -16,57 +15,37 @@ describe('Placement Groups navigation', () => { }); /* - * - 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: { - beta: true, - enabled: true, - }, - }).as('getFeatureFlags'); - cy.visitWithLogin('/linodes'); - cy.wait('@getFeatureFlags'); 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. + * - Confirm navigation patterns to the create drawer */ - it('does not show Placement Groups navigation item when feature is disabled', () => { - mockAppendFeatureFlags({ - placementGroups: { - beta: true, - enabled: false, - }, - }).as('getFeatureFlags'); - - cy.visitWithLogin('/linodes'); - cy.wait('@getFeatureFlags'); - - 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: { - beta: true, - enabled: false, - }, - }).as('getFeatureFlags'); - + it.only('can navigate to a placement group details page', () => { cy.visitWithLogin('/placement-groups'); - cy.wait('@getFeatureFlags'); - cy.findByText('Not Found').should('be.visible'); + ui.button + .findByTitle('Create Placement Group') + .should('be.visible') + .click(); + cy.url().should('endWith', '/placement-groups/create'); + + ui.drawer + .findByTitle('Create Placement Group') + .should('be.visible') + .within(() => { + ui.button + .findByAttribute('aria-label', 'Close drawer') + .should('be.visible') + .click(); + }); + + cy.url().should('endWith', '/placement-groups'); }); }); diff --git a/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts b/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts index 6b9f172f397..b857c69e1ca 100644 --- a/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts +++ b/packages/manager/cypress/e2e/core/placementGroups/update-placement-group-label.spec.ts @@ -2,11 +2,6 @@ * @file Integration tests for Placement Group update label flows. */ -import { - mockAppendFeatureFlags, - mockGetFeatureFlagClientstream, -} from 'support/intercepts/feature-flags'; -import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel, randomNumber } from 'support/util/random'; import { mockGetPlacementGroups, @@ -15,7 +10,6 @@ import { } from 'support/intercepts/placement-groups'; import { accountFactory, placementGroupFactory } from 'src/factories'; import { mockGetAccount } from 'support/intercepts/account'; -import type { Flags } from 'src/featureFlags'; import { chooseRegion } from 'support/util/regions'; import { ui } from 'support/ui'; @@ -24,13 +18,6 @@ const mockAccount = accountFactory.build(); describe('Placement Group update label flow', () => { // Mock the VM Placement Groups feature flag to be enabled for each test in this block. beforeEach(() => { - mockAppendFeatureFlags({ - placementGroups: makeFeatureFlagData({ - beta: true, - enabled: true, - }), - }); - mockGetFeatureFlagClientstream(); mockGetAccount(mockAccount); }); diff --git a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx index cffcd3c848f..50f5d00e922 100644 --- a/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx +++ b/packages/manager/src/components/PrimaryNav/PrimaryNav.tsx @@ -173,7 +173,6 @@ export const PrimaryNav = (props: PrimaryNavProps) => { hide: !isPlacementGroupsEnabled, href: '/placement-groups', icon: , - isBeta: flags.placementGroups?.beta, }, ], [ @@ -251,7 +250,6 @@ export const PrimaryNav = (props: PrimaryNavProps) => { isManaged, allowMarketplacePrefetch, isPlacementGroupsEnabled, - flags.placementGroups, isACLPEnabled, ] ); diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 7022c53402d..4bb15c6412a 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -28,7 +28,6 @@ const options: { flag: keyof Flags; label: string }[] = [ { 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' }, { flag: 'dbaasV2', label: 'Databases V2 Beta' }, diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index cf6ecc709e4..6532ff27f75 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -107,7 +107,6 @@ export interface Flags { objectStorageGen2: BaseFeatureFlag; oneClickApps: OneClickApp; oneClickAppsDocsOverride: Record; - placementGroups: BetaFeatureFlag; productInformationBanners: ProductInformationBannerFlag[]; promos: boolean; promotionalOffers: PromotionalOffer[]; diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.test.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.test.tsx index 76657711071..46e007f0958 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.test.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Details/Details.test.tsx @@ -41,12 +41,9 @@ describe('Linode Create Details', () => { ).toBeVisible(); }); - it('renders an placement group details if the flag is on', async () => { + it('renders a placement group details', async () => { const { getByText } = renderWithThemeAndHookFormContext({ component:
    , - options: { - flags: { placementGroups: { beta: true, enabled: true } }, - }, }); await waitFor(() => { @@ -58,19 +55,6 @@ describe('Linode Create Details', () => { }); }); - it('does not render the placement group select if the flag is off', () => { - const { queryByText } = renderWithThemeAndHookFormContext({ - component:
    , - options: { - flags: { placementGroups: { beta: true, enabled: false } }, - }, - }); - - expect( - queryByText('Select a region above to see available Placement Groups.') - ).toBeNull(); - }); - it('does not render the tag select when cloning', () => { const { queryByText } = renderWithThemeAndHookFormContext({ component:
    , diff --git a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.test.tsx b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.test.tsx index aee1c6df8c9..583cc984ebe 100644 --- a/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.test.tsx +++ b/packages/manager/src/features/Linodes/MigrateLinode/ConfigureForm.test.tsx @@ -174,10 +174,7 @@ describe('ConfigureForm component with price comparison', () => { {...props} currentRegion="us-east" selectedRegion="us-central" - />, - { - flags: { placementGroups: { beta: true, enabled: true } }, - } + /> ); // Verify that the PlacementGroupsSelect component is rendered diff --git a/packages/manager/src/features/PlacementGroups/utils.test.ts b/packages/manager/src/features/PlacementGroups/utils.test.ts index 3c44ddc531b..70171f63dc0 100644 --- a/packages/manager/src/features/PlacementGroups/utils.test.ts +++ b/packages/manager/src/features/PlacementGroups/utils.test.ts @@ -217,12 +217,7 @@ describe('getPlacementGroupLinodes', () => { }); describe('useIsPlacementGroupsEnabled', () => { - it('returns true if the feature flag is enabled and the account has the Placement Group capability', () => { - queryMocks.useFlags.mockReturnValue({ - placementGroups: { - enabled: true, - }, - }); + it('returns true if the account has the Placement Group capability', () => { queryMocks.useAccount.mockReturnValue({ data: { capabilities: ['Placement Group'], @@ -235,23 +230,6 @@ describe('useIsPlacementGroupsEnabled', () => { }); }); - it('returns false if the feature flag is disabled', () => { - queryMocks.useFlags.mockReturnValue({ - placementGroups: { - enabled: false, - }, - }); - queryMocks.useAccount.mockReturnValue({ - data: { - capabilities: ['Placement Group'], - }, - }); - - const { result } = renderHook(() => useIsPlacementGroupsEnabled()); - expect(result.current).toStrictEqual({ - isPlacementGroupsEnabled: false, - }); - }); it('returns false if the account does not have the Placement Group capability', () => { queryMocks.useFlags.mockReturnValue({ placementGroups: { diff --git a/packages/manager/src/features/PlacementGroups/utils.ts b/packages/manager/src/features/PlacementGroups/utils.ts index c466a0ff6a2..740a0c77685 100644 --- a/packages/manager/src/features/PlacementGroups/utils.ts +++ b/packages/manager/src/features/PlacementGroups/utils.ts @@ -132,12 +132,8 @@ export const useIsPlacementGroupsEnabled = (): { return { isPlacementGroupsEnabled: false }; } - const hasAccountCapability = account?.capabilities?.includes( - 'Placement Group' - ); - const isFeatureFlagEnabled = flags.placementGroups?.enabled; const isPlacementGroupsEnabled = Boolean( - hasAccountCapability && isFeatureFlagEnabled + account?.capabilities?.includes('Placement Group') ); return { isPlacementGroupsEnabled }; From 11920574c7f2a506c1295134ba968f7f945b8401 Mon Sep 17 00:00:00 2001 From: Alban Bailly <130582365+abailly-akamai@users.noreply.github.com> Date: Thu, 5 Sep 2024 21:21:46 -0400 Subject: [PATCH 24/67] fix: [M3-8529] - Weblish line wrapping (#10893) * Match weblish cols to SSH lish * Added changeset: Weblish line wrapping --- packages/manager/.changeset/pr-10893-fixed-1725548041320.md | 5 +++++ packages/manager/src/features/Lish/Weblish.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 packages/manager/.changeset/pr-10893-fixed-1725548041320.md diff --git a/packages/manager/.changeset/pr-10893-fixed-1725548041320.md b/packages/manager/.changeset/pr-10893-fixed-1725548041320.md new file mode 100644 index 00000000000..681ce34fab7 --- /dev/null +++ b/packages/manager/.changeset/pr-10893-fixed-1725548041320.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Weblish line wrapping ([#10893](https://github.com/linode/manager/pull/10893)) diff --git a/packages/manager/src/features/Lish/Weblish.tsx b/packages/manager/src/features/Lish/Weblish.tsx index b487d68beae..54a376bbb0f 100644 --- a/packages/manager/src/features/Lish/Weblish.tsx +++ b/packages/manager/src/features/Lish/Weblish.tsx @@ -107,7 +107,7 @@ export class Weblish extends React.Component { const { group, label } = linode; this.terminal = new Terminal({ - cols: 120, + cols: 80, cursorBlink: true, fontFamily: '"Ubuntu Mono", monospace, sans-serif', rows: 40, From 3e1414f35f4bbd39ea67ddf07a329454556db915 Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Fri, 6 Sep 2024 12:50:40 +0530 Subject: [PATCH 25/67] test: [M3-8518] - Add unit tests for SelectableTableRow component (#10890) * Add unit tests for SelectableTableRow component * Wrap SelectableTableRow with wrapWithTableBody * Added changeset: Add unit tests for SelectableTableRow component * Refactor unit tests to use for toggling --- .../pr-10890-tests-1725525454848.md | 5 ++ .../SelectableTableRow.test.tsx | 78 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 packages/manager/.changeset/pr-10890-tests-1725525454848.md create mode 100644 packages/manager/src/components/SelectableTableRow/SelectableTableRow.test.tsx diff --git a/packages/manager/.changeset/pr-10890-tests-1725525454848.md b/packages/manager/.changeset/pr-10890-tests-1725525454848.md new file mode 100644 index 00000000000..84b807992ef --- /dev/null +++ b/packages/manager/.changeset/pr-10890-tests-1725525454848.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tests +--- + +Add unit tests for SelectableTableRow component ([#10890](https://github.com/linode/manager/pull/10890)) diff --git a/packages/manager/src/components/SelectableTableRow/SelectableTableRow.test.tsx b/packages/manager/src/components/SelectableTableRow/SelectableTableRow.test.tsx new file mode 100644 index 00000000000..143c26a228d --- /dev/null +++ b/packages/manager/src/components/SelectableTableRow/SelectableTableRow.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, render } from '@testing-library/react'; +import * as React from 'react'; + +import { wrapWithTableBody } from 'src/utilities/testHelpers'; + +import { TableCell } from '../TableCell'; +import { SelectableTableRow } from './SelectableTableRow'; + +const cells = [ + { id: 1, label: 'child-cell-1' }, + { id: 2, label: 'child-cell-2' }, + { id: 3, label: 'child-cell-3' }, +]; + +const defaultArgs = { + children: cells.map((cell) => ( + {cell.label} + )), + handleToggleCheck: vi.fn(), + isChecked: false, +}; + +const ariaLabel = 'Select all entities on page'; + +describe('SelectableTableRow', () => { + it('should render table row with checkbox and child content', () => { + const { getAllByText, getByRole, getByText } = render( + wrapWithTableBody() + ); + + const checkbox = getByRole('checkbox', { + name: ariaLabel, + }); + + expect(checkbox).toBeInTheDocument(); + + cells.forEach((cell) => { + expect(getByText(cell.label)).toBeInTheDocument(); + }); + + expect(getAllByText(/child-cell-/i)).toHaveLength(cells.length); + }); + + it('should call handleToggleCheck on click', () => { + const { getByRole } = render( + wrapWithTableBody() + ); + + const checkbox = getByRole('checkbox', { + name: ariaLabel, + }); + + fireEvent.click(checkbox); + expect(defaultArgs.handleToggleCheck).toHaveBeenCalled(); + }); + + it.each([ + [true, 'checked'], + [false, 'unchecked'], + ])( + 'should correctly reflect the checkbox state when isChecked is %s', + (isChecked) => { + const { getByRole } = render( + wrapWithTableBody( + + ) + ); + + const checkbox = getByRole('checkbox', { name: ariaLabel }); + + if (isChecked) { + expect(checkbox).toBeChecked(); + } else { + expect(checkbox).not.toBeChecked(); + } + } + ); +}); From 766273a6a60679e06a9d20b36bb956fb029ffd3a Mon Sep 17 00:00:00 2001 From: Purvesh Makode Date: Fri, 6 Sep 2024 12:51:41 +0530 Subject: [PATCH 26/67] refactor: [M3-8331] - useToastNotification async toasts (#10841) * Refactor useToastNotification async toasts * Added changeset: Refactor useToastNotification async toasts * Clean up and refactor async toasts configuration * Update e2e test for attach-volume.spec.ts * Update async toasts e2e tests in clone-linode.spec.ts * Update async toast e2e tests in linode-storage.spec.ts * Update async toasts e2e tests in machine-image-upload.spec.ts * Update e2e tests and disk event messages * Update other e2e tests * Refactor asyncToasts and useToastNotifications * Update types and comments * Add unit test cases * Refactor createToast function * Update createToast to conditionally pass parameters and adjust unit tests * few fixes on asyncToasts.tsx * Update event messages for disk * Clean up: Remove test cases which are already covered --- .../pr-10841-tech-stories-1724763185078.md | 5 + .../core/images/machine-image-upload.spec.ts | 4 +- .../core/images/smoke-create-image.spec.ts | 2 +- .../e2e/core/linodes/clone-linode.spec.ts | 2 +- .../e2e/core/linodes/linode-config.spec.ts | 2 +- .../e2e/core/linodes/linode-storage.spec.ts | 8 +- .../e2e/core/linodes/resize-linode.spec.ts | 4 +- .../e2e/core/volumes/attach-volume.spec.ts | 4 +- .../e2e/core/volumes/delete-volume.spec.ts | 2 +- .../e2e/core/volumes/upgrade-volume.spec.ts | 6 +- .../src/features/Events/asyncToasts.test.tsx | 140 ++++++++++ .../src/features/Events/asyncToasts.tsx | 95 +++++++ .../src/features/Events/factories/backup.tsx | 5 +- .../src/features/Events/factories/disk.tsx | 58 +++-- .../src/features/Events/factories/image.tsx | 3 +- .../src/hooks/useToastNotifications.tsx | 239 +----------------- 16 files changed, 313 insertions(+), 266 deletions(-) create mode 100644 packages/manager/.changeset/pr-10841-tech-stories-1724763185078.md create mode 100644 packages/manager/src/features/Events/asyncToasts.test.tsx create mode 100644 packages/manager/src/features/Events/asyncToasts.tsx diff --git a/packages/manager/.changeset/pr-10841-tech-stories-1724763185078.md b/packages/manager/.changeset/pr-10841-tech-stories-1724763185078.md new file mode 100644 index 00000000000..6173b8d24de --- /dev/null +++ b/packages/manager/.changeset/pr-10841-tech-stories-1724763185078.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Tech Stories +--- + +Refactor useToastNotification async toasts ([#10841](https://github.com/linode/manager/pull/10841)) 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 fbd28300d66..25339973135 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 @@ -81,9 +81,7 @@ const eventIntercept = ( * @param message - Expected failure message. */ const assertFailed = (label: string, id: string, message: string) => { - ui.toast.assertMessage( - `There was a problem uploading image ${label}: ${message}` - ); + ui.toast.assertMessage(`Image ${label} could not be uploaded: ${message}`); cy.get(`[data-qa-image-cell="${id}"]`).within(() => { fbtVisible(label); diff --git a/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts b/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts index f6e0a0e1cdc..1ee83565388 100644 --- a/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/smoke-create-image.spec.ts @@ -107,6 +107,6 @@ describe('create image (using mocks)', () => { cy.wait('@getEvents'); // Verify a success toast shows - ui.toast.assertMessage('Image My Config successfully created.'); + ui.toast.assertMessage('Image My Config has been created.'); }); }); 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 cd9a78f2258..c64e43abba6 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -106,7 +106,7 @@ 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} has been 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 a6ea27fbc80..2e73ca80de0 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -381,7 +381,7 @@ describe('Linode Config management', () => { // Confirm toast message and that UI updates to reflect clone in progress. ui.toast.assertMessage( - `Linode ${sourceLinode.label} successfully cloned to ${destLinode.label}.` + `Linode ${sourceLinode.label} has been cloned to ${destLinode.label}.` ); cy.findByText(/CLONING \(\d+%\)/).should('be.visible'); }); 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 6cd87caaf14..0e256bbbb41 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts @@ -148,7 +148,9 @@ describe('linode storage tab', () => { cy.wait('@deleteDisk').its('response.statusCode').should('eq', 200); cy.findByText('Deleting', { exact: false }).should('be.visible'); ui.button.findByTitle('Add a Disk').should('be.enabled'); - ui.toast.assertMessage(`Disk ${diskName} successfully deleted.`); + ui.toast.assertMessage( + `Disk ${diskName} on Linode ${linode.label} has been deleted.` + ); cy.findByLabelText('List of Disks').within(() => { cy.contains(diskName).should('not.exist'); }); @@ -209,7 +211,9 @@ describe('linode storage tab', () => { cy.wait('@resizeDisk').its('response.statusCode').should('eq', 200); ui.toast.assertMessage('Disk queued for resizing.'); // cy.findByText('Resizing', { exact: false }).should('be.visible'); - ui.toast.assertMessage(`Disk ${diskName} successfully resized.`); + ui.toast.assertMessage( + `Disk ${diskName} on Linode ${linode.label} has been resized.` + ); }); }); }); 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 0d003ddd864..ba2107cd62a 100644 --- a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts @@ -186,7 +186,9 @@ describe('resize linode', () => { }); // Wait until the disk resize is done. - ui.toast.assertMessage(`Disk ${diskName} successfully resized.`); + ui.toast.assertMessage( + `Disk ${diskName} on Linode ${linode.label} has been resized.` + ); interceptLinodeResize(linode.id).as('linodeResize'); cy.visitWithLogin(`/linodes/${linode.id}?resize=true`); 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 6e2835aa20b..76c2ec63a0f 100644 --- a/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts @@ -114,7 +114,9 @@ describe('volume attach and detach flows', () => { // Confirm that volume has been attached to Linode. cy.wait('@attachVolume').its('response.statusCode').should('eq', 200); - ui.toast.assertMessage(`Volume ${volume.label} successfully attached.`); + ui.toast.assertMessage( + `Volume ${volume.label} has been attached to Linode ${linode.label}.` + ); cy.findByText(volume.label) .should('be.visible') .closest('tr') 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 fe441dcf865..7897d0c7f2c 100644 --- a/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts @@ -87,7 +87,7 @@ describe('volume delete flow', () => { // Confirm that volume is deleted. cy.wait('@deleteVolume').its('response.statusCode').should('eq', 200); cy.findByText(volume.label).should('not.exist'); - ui.toast.assertMessage('Volume successfully deleted.'); + ui.toast.assertMessage(`Volume ${volume.label} has been deleted.`); } ); }); diff --git a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts index 59436b4e983..f6d807e4c15 100644 --- a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts @@ -87,7 +87,7 @@ describe('volume upgrade/migration', () => { cy.findByText('active').should('be.visible'); - ui.toast.assertMessage(`Volume ${volume.label} successfully upgraded.`); + ui.toast.assertMessage(`Volume ${volume.label} has been migrated to NVMe.`); }); it('can upgrade an attached volume from the volumes landing page', () => { @@ -178,7 +178,7 @@ describe('volume upgrade/migration', () => { cy.findByText('active').should('be.visible'); - ui.toast.assertMessage(`Volume ${volume.label} successfully upgraded.`); + ui.toast.assertMessage(`Volume ${volume.label} has been migrated to NVMe.`); }); it('can upgrade an attached volume from the linode details page', () => { @@ -265,6 +265,6 @@ describe('volume upgrade/migration', () => { cy.findByText('active').should('be.visible'); - ui.toast.assertMessage(`Volume ${volume.label} successfully upgraded.`); + ui.toast.assertMessage(`Volume ${volume.label} has been migrated to NVMe.`); }); }); diff --git a/packages/manager/src/features/Events/asyncToasts.test.tsx b/packages/manager/src/features/Events/asyncToasts.test.tsx new file mode 100644 index 00000000000..0ed1b44a26a --- /dev/null +++ b/packages/manager/src/features/Events/asyncToasts.test.tsx @@ -0,0 +1,140 @@ +import { createToast } from './asyncToasts'; + +describe('createToast', () => { + it('should handle case with both failure and success options as true or empty objects', () => { + const trueOptions = { failure: true, success: true }; + const emptyObjectOptions = { failure: {}, success: {} }; + + [trueOptions, emptyObjectOptions].forEach((options) => { + const result = createToast(options); + const expected = { + failure: { + message: expect.any(Function), + }, + success: { + message: expect.any(Function), + }, + }; + + expect(result).toEqual(expected); + }); + }); + + it('should handle case with only failure option as true or empty object or with success as false', () => { + const scenarios = [ + { failure: true }, + { failure: {} }, + { failure: true, success: false }, + { failure: {}, success: false }, + ]; + + scenarios.forEach((options) => { + const result = createToast(options); + const expected = { + failure: { + message: expect.any(Function), + }, + }; + + expect(result).toEqual(expected); + }); + }); + + it('should handle case with only success option as true or empty object or with failure as false', () => { + const scenarios = [ + { success: true }, + { success: {} }, + { failure: false, success: true }, + { failure: false, success: {} }, + ]; + + scenarios.forEach((options) => { + const result = createToast(options); + const expected = { + success: { + message: expect.any(Function), + }, + }; + + expect(result).toEqual(expected); + }); + }); + + it('should return an empty object if both failure and success are false or not provided', () => { + const falseOptions = { failure: false, success: false }; + const emptyOptions = {}; + [falseOptions, emptyOptions].forEach((options) => { + const result = createToast(options); + + expect(result).toEqual({}); + }); + }); + + it('should handle cases with specific values for only failure or success options', () => { + // Only the failure options with specific values + const failureOnlyOptions = { + failure: { persist: true }, + }; + const result1 = createToast(failureOnlyOptions); + const expected1 = { + failure: { + message: expect.any(Function), + persist: true, + }, + }; + expect(result1).toEqual(expected1); + + // Only the success options with specific values + const successOnlyOptions = { + success: { invertVariant: true, persist: false }, + }; + const result2 = createToast(successOnlyOptions); + const expected2 = { + success: { + invertVariant: true, + message: expect.any(Function), + persist: false, + }, + }; + expect(result2).toEqual(expected2); + }); + + it('should handle case with both failure and success options with specific values', () => { + const options1 = { + failure: { invertVariant: true, persist: true }, + success: { invertVariant: true, persist: false }, + }; + const result1 = createToast(options1); + const expected1 = { + failure: { + invertVariant: true, + message: expect.any(Function), + persist: true, + }, + success: { + invertVariant: true, + message: expect.any(Function), + persist: false, + }, + }; + expect(result1).toEqual(expected1); + + const options2 = { + failure: { persist: true }, + success: { invertVariant: true }, + }; + + const result2 = createToast(options2); + const expected2 = { + failure: { + message: expect.any(Function), + persist: true, + }, + success: { + invertVariant: true, + message: expect.any(Function), + }, + }; + expect(result2).toEqual(expected2); + }); +}); diff --git a/packages/manager/src/features/Events/asyncToasts.tsx b/packages/manager/src/features/Events/asyncToasts.tsx new file mode 100644 index 00000000000..5c3cdc9553e --- /dev/null +++ b/packages/manager/src/features/Events/asyncToasts.tsx @@ -0,0 +1,95 @@ +import { getEventMessage } from './utils'; + +import type { Event, EventAction } from '@linode/api-v4'; + +interface ToastMessage { + /** + * If true, the toast will be displayed with an error variant for success messages \ + * or a success variant for error messages. + */ + invertVariant?: boolean; + message: ((event: Event) => JSX.Element | null | string | undefined) | string; + persist?: boolean; +} + +interface Toast { + failure?: ToastMessage; + success?: ToastMessage; +} + +type Toasts = { + [key in EventAction]?: Toast; +}; + +interface ToastOption { + invertVariant?: boolean; + persist?: boolean; +} + +interface ToastOptions { + failure?: ToastOption | boolean; + success?: ToastOption | boolean; +} + +export const createToast = (options: ToastOptions) => { + const toastConfig: Toast = {}; + + const getToastMessage = (option: ToastOption | boolean): ToastMessage => { + const message: ToastMessage['message'] = (e) => getEventMessage(e); + + if (typeof option === 'boolean') { + return { message }; + } + + return { + message, + ...(option.invertVariant !== undefined && { + invertVariant: option.invertVariant, + }), + ...(option.persist !== undefined && { persist: option.persist }), + }; + }; + + if (options.failure) { + toastConfig.failure = getToastMessage(options.failure); + } + + if (options.success) { + toastConfig.success = getToastMessage(options.success); + } + + return toastConfig; +}; + +/** + * This constant defines toast notifications that will be displayed + * when our events polling system gets a new event. + * + * Use this feature to notify users of *asynchronous tasks* such as migrating a Linode. + * + * 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. + */ +export const toasts: Toasts = { + backups_restore: createToast({ failure: { persist: true } }), + disk_delete: createToast({ failure: false, success: true }), + disk_imagize: createToast({ failure: { persist: true }, success: true }), + disk_resize: createToast({ failure: { persist: true }, success: true }), + image_delete: createToast({ failure: true, success: true }), + image_upload: createToast({ failure: { persist: true }, success: true }), + linode_clone: createToast({ failure: true, success: true }), + linode_migrate: createToast({ failure: true, success: true }), + linode_migrate_datacenter: createToast({ failure: true, success: true }), + linode_resize: createToast({ failure: true, success: true }), + linode_snapshot: createToast({ failure: { persist: true } }), + longviewclient_create: createToast({ failure: true, success: true }), + tax_id_invalid: createToast({ + failure: true, + success: { invertVariant: true, persist: true }, + }), + volume_attach: createToast({ failure: true, success: true }), + volume_create: createToast({ failure: true, success: true }), + volume_delete: createToast({ failure: true, success: true }), + volume_detach: createToast({ failure: true, success: true }), + volume_migrate: createToast({ failure: true, success: true }), +}; diff --git a/packages/manager/src/features/Events/factories/backup.tsx b/packages/manager/src/features/Events/factories/backup.tsx index 31344a45197..0e45776e06d 100644 --- a/packages/manager/src/features/Events/factories/backup.tsx +++ b/packages/manager/src/features/Events/factories/backup.tsx @@ -22,11 +22,12 @@ 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. + Learn more about limits and considerations + . ), finished: (e) => ( diff --git a/packages/manager/src/features/Events/factories/disk.tsx b/packages/manager/src/features/Events/factories/disk.tsx index 8168cdd76e4..761fceafb21 100644 --- a/packages/manager/src/features/Events/factories/disk.tsx +++ b/packages/manager/src/features/Events/factories/disk.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; +import { sendLinodeDiskEvent } from 'src/utilities/analytics/customEventAnalytics'; import { EventLink } from '../EventLink'; @@ -76,32 +77,37 @@ export const disk: PartialEventMap<'disk'> = { disk_duplicate: { failed: (e) => ( <> - Disk on Linode could{' '} - not be duplicated. + Disk on Linode{' '} + could not be{' '} + duplicated. ), finished: (e) => ( <> - Disk on Linode has been{' '} - duplicated. + Disk on Linode{' '} + has been duplicated + . ), notification: (e) => ( <> - Disk on Linode has been{' '} - duplicated. + Disk on Linode{' '} + has been duplicated + . ), scheduled: (e) => ( <> - Disk on Linode is scheduled to be{' '} + Disk on Linode{' '} + is scheduled to be{' '} duplicated. ), started: (e) => ( <> - Disk on Linode is being{' '} - duplicated. + Disk on Linode{' '} + is being duplicated + . ), }, @@ -111,7 +117,7 @@ export const disk: PartialEventMap<'disk'> = { Image could{' '} not be created.{' '} - Learn more about image technical specifications. + Learn more about image technical specifications . @@ -138,36 +144,46 @@ export const disk: PartialEventMap<'disk'> = { disk_resize: { failed: (e) => ( <> - A disk on Linode could{' '} - not be resized.{' '} - + Disk on Linode{' '} + could not be{' '} + resized.{' '} + { + sendLinodeDiskEvent( + 'Resize', + 'Click:link', + 'Disk resize failed toast' + ); + }} + to="https://www.linode.com/docs/products/compute/compute-instances/guides/disks-and-storage/" + > Learn more ), - finished: (e) => ( <> - A disk on Linode has been{' '} - resized. + Disk on Linode{' '} + has been resized. ), notification: (e) => ( <> - A disk on Linode has been{' '} - resized. + Disk on Linode{' '} + has been resized. ), scheduled: (e) => ( <> - A disk on Linode is scheduled to be{' '} + Disk on Linode{' '} + is scheduled to be{' '} resized. ), started: (e) => ( <> - A disk on Linode is being{' '} - resized. + Disk on Linode{' '} + is being resized. ), }, diff --git a/packages/manager/src/features/Events/factories/image.tsx b/packages/manager/src/features/Events/factories/image.tsx index dbe4ef39cf2..a9912b6ab87 100644 --- a/packages/manager/src/features/Events/factories/image.tsx +++ b/packages/manager/src/features/Events/factories/image.tsx @@ -53,8 +53,7 @@ export const image: PartialEventMap<'image'> = { finished: (e) => ( <> - Image has been{' '} - uploaded. + Image is now available. ), notification: (e) => ( diff --git a/packages/manager/src/hooks/useToastNotifications.tsx b/packages/manager/src/hooks/useToastNotifications.tsx index 51633c484b3..8198aa0bb9c 100644 --- a/packages/manager/src/hooks/useToastNotifications.tsx +++ b/packages/manager/src/hooks/useToastNotifications.tsx @@ -1,220 +1,21 @@ import { useSnackbar } from 'notistack'; -import * as React from 'react'; -import { Link } from 'src/components/Link'; -import { SupportLink } from 'src/components/SupportLink'; -import { Typography } from 'src/components/Typography'; -import { sendLinodeDiskEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { toasts } from 'src/features/Events/asyncToasts'; -import type { Event, EventAction } from '@linode/api-v4'; +import type { Event } from '@linode/api-v4'; export const getLabel = (event: Event) => event.entity?.label ?? ''; export const getSecondaryLabel = (event: Event) => event.secondary_entity?.label ?? ''; -const formatLink = (text: string, link: string, handleClick?: () => void) => { - return ( - - {text} - - ); -}; - -interface ToastMessage { - link?: JSX.Element; - 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 = { - [key in EventAction]?: Toast; -}; - -/** - * This constant defines toast notifications that will be displayed - * when our events polling system gets a new event. - * - * Use this feature to notify users of *asynchronous tasks* such as migrating a Linode. - * - * 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: { - 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: { - 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: { - 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: { - 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: { message: (e) => `Error deleting Image ${getLabel(e)}.` }, - success: { message: (e) => `Image ${getLabel(e)} successfully deleted.` }, - }, - image_upload: { - failure: { - message: (e) => { - const isDeletion = e.message === 'Upload canceled.'; - - if (isDeletion) { - return undefined; - } - - return `There was a problem uploading image ${getLabel( - e - )}: ${e.message?.replace(/(\d+)/g, '$1 MB')}`; - }, - persist: true, - }, - success: { message: (e) => `Image ${getLabel(e)} is now available.` }, - }, - linode_clone: { - failure: { message: (e) => `Error cloning Linode ${getLabel(e)}.` }, - success: { - message: (e) => - `Linode ${getLabel(e)} successfully cloned to ${getSecondaryLabel(e)}.`, - }, - }, - linode_migrate: { - failure: { message: (e) => `Error migrating Linode ${getLabel(e)}.` }, - success: { message: (e) => `Linode ${getLabel(e)} successfully migrated.` }, - }, - linode_migrate_datacenter: { - failure: { message: (e) => `Error migrating Linode ${getLabel(e)}.` }, - success: { message: (e) => `Linode ${getLabel(e)} successfully migrated.` }, - }, - linode_resize: { - failure: { message: (e) => `Error resizing Linode ${getLabel(e)}.` }, - success: { message: (e) => `Linode ${getLabel(e)} successfully resized.` }, - }, - linode_snapshot: { - 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: { - 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. Please check your Tax ID for accuracy or contact support for assistance.', - persist: true, - }, - }, - volume_attach: { - failure: { message: (e) => `Error attaching Volume ${getLabel(e)}.` }, - success: { message: (e) => `Volume ${getLabel(e)} successfully attached.` }, - }, - volume_create: { - failure: { message: (e) => `Error creating Volume ${getLabel(e)}.` }, - success: { message: (e) => `Volume ${getLabel(e)} successfully created.` }, - }, - volume_delete: { - failure: { message: 'Error deleting Volume.' }, - success: { message: 'Volume successfully deleted.' }, - }, - volume_detach: { - failure: { message: (e) => `Error detaching Volume ${getLabel(e)}.` }, - success: { message: (e) => `Volume ${getLabel(e)} successfully detached.` }, - }, - volume_migrate: { - failure: { message: (e) => `Error upgrading Volume ${getLabel(e)}.` }, - success: { message: (e) => `Volume ${getLabel(e)} successfully upgraded.` }, - }, -}; const getToastMessage = ( - toastMessage: ((event: Event) => string | undefined) | string, + toastMessage: + | ((event: Event) => JSX.Element | null | string | undefined) + | string, event: Event -): string | undefined => +): JSX.Element | null | 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; } => { @@ -229,41 +30,25 @@ export const useToastNotifications = (): { const isSuccessEvent = ['finished', 'notification'].includes(event.status); if (isSuccessEvent && toastInfo.success) { - const { link, message, persist } = toastInfo.success; + const { invertVariant, message, persist } = toastInfo.success; const successMessage = getToastMessage(message, event); if (successMessage) { - const formattedSuccessMessage = createFormattedMessage( - successMessage, - link, - false - ); - - enqueueSnackbar(formattedSuccessMessage, { + enqueueSnackbar(successMessage, { persist: persist ?? false, - variant: toastInfo.invertVariant ? 'error' : 'success', + variant: invertVariant ? 'error' : 'success', }); } } if (event.status === 'failed' && toastInfo.failure) { - const { link, message, persist } = toastInfo.failure; + const { invertVariant, message, persist } = toastInfo.failure; const failureMessage = getToastMessage(message, event); if (failureMessage) { - const hasSupportLink = failureMessage - .toLowerCase() - .includes('contact support'); - - const formattedFailureMessage = createFormattedMessage( - failureMessage, - link, - hasSupportLink - ); - - enqueueSnackbar(formattedFailureMessage, { + enqueueSnackbar(failureMessage, { persist: persist ?? false, - variant: toastInfo.invertVariant ? 'success' : 'error', + variant: invertVariant ? 'success' : 'error', }); } } From d5798d0654abe77eea1e38b8755dd8d910dbde7d Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Fri, 6 Sep 2024 09:57:03 -0500 Subject: [PATCH 27/67] refactor: [M3-8186] - Clean up DebouncedSearchTextField and fix instances where debouncing is not happening (#10813) * unit test coverage for HostNameTableCell * Revert "unit test coverage for HostNameTableCell" This reverts commit b274baf67e27d79fd4e764607ded7c5aa755ee8b. * Apply fix to avoid unintended re-renders or effect executions * fix debouncing issue in SelectLinodePanel and LinodeSelectTable * code cleanup * Update LongviewClients.tsx * code cleanup * Code clean up - remove customValue prop * PR feedback - @hkhalil-akamai * Remove onSearch from dependency to avoid unwanted re-triggers * More code cleanup DebouncedSearchTextField component, removed redundant implementation of clear icon. * PR feedback - @mjac0bs --- .../DebouncedSearchTextField.tsx | 54 +++++++++---------- .../DebouncedSearchTextfield.test.tsx | 1 + .../LinodeTransferTable.tsx | 10 ++-- .../EntityTransfersCreate/TransferTable.tsx | 5 +- .../shared/LinodeSelectTable.tsx | 15 +++--- .../SelectLinodePanel.test.tsx | 30 ++++++----- .../SelectLinodePanel/SelectLinodePanel.tsx | 12 ++--- .../DetailTabs/Processes/ProcessesLanding.tsx | 2 +- .../LongviewLanding/LongviewClients.tsx | 35 ++++++------ .../PlacementGroupsLinodes.tsx | 5 +- .../PlacementGroupsLanding.tsx | 25 ++------- .../StackScriptBase/StackScriptBase.tsx | 1 + .../VPCs/VPCDetail/VPCSubnetsTable.tsx | 1 + 13 files changed, 96 insertions(+), 100 deletions(-) diff --git a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx index cb5c23eafc7..1db938d8756 100644 --- a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx +++ b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx @@ -2,28 +2,22 @@ import Clear from '@mui/icons-material/Clear'; import Search from '@mui/icons-material/Search'; import { styled } from '@mui/material/styles'; import * as React from 'react'; +import { debounce } from 'throttle-debounce'; import { CircleProgress } from 'src/components/CircleProgress'; import { InputAdornment } from 'src/components/InputAdornment'; -import { TextField, TextFieldProps } from 'src/components/TextField'; +import { TextField } from 'src/components/TextField'; import { IconButton } from '../IconButton'; +import type { TextFieldProps } from 'src/components/TextField'; + export interface DebouncedSearchProps extends TextFieldProps { className?: string; /** * Whether to show a clear button at the end of the input. */ clearable?: boolean; - /** - * Including this prop will disable this field from being self-managed. - * The user must then manage the state of the text field and provide a - * value and change handler. - */ - customValue?: { - onChange: (newValue: string | undefined) => void; - value: string | undefined; - }; /** * Interval in milliseconds of time that passes before search queries are accepted. * @default 400 @@ -39,8 +33,9 @@ export interface DebouncedSearchProps extends TextFieldProps { /** * Function to perform when searching for query */ - onSearch?: (query: string) => void; + onSearch: (query: string) => void; placeholder?: string; + value: string; } export const DebouncedSearchTextField = React.memo( @@ -49,7 +44,6 @@ export const DebouncedSearchTextField = React.memo( InputProps, className, clearable, - customValue, debounceTime, defaultValue, hideLabel, @@ -57,25 +51,28 @@ export const DebouncedSearchTextField = React.memo( label, onSearch, placeholder, + value, ...restOfTextFieldProps } = props; - // Manage the textfield state if customValue is not provided - const managedValue = React.useState(); - const [textFieldValue, setTextFieldValue] = customValue - ? [customValue.value, customValue.onChange] - : managedValue; + const [textFieldValue, setTextFieldValue] = React.useState(''); + + // Memoize the debounced onChange handler to prevent unnecessary re-creations. + const debouncedOnChange = React.useMemo( + () => + debounce(debounceTime ?? 400, (e) => { + onSearch(e.target.value); + setTextFieldValue(e.target.value); + }), + [debounceTime, onSearch] + ); + // Synchronize the internal state with the prop value when the value prop changes. React.useEffect(() => { - if (textFieldValue != undefined) { - const timeout = setTimeout( - () => onSearch && onSearch(textFieldValue), - debounceTime !== undefined ? debounceTime : 400 - ); - return () => clearTimeout(timeout); + if (value && value !== textFieldValue) { + setTextFieldValue(value); } - return undefined; - }, [debounceTime, onSearch, textFieldValue]); + }, [value]); return ( { + setTextFieldValue(''); + onSearch(''); + }} aria-label="Clear" - onClick={() => setTextFieldValue('')} size="small" > setTextFieldValue(e.target.value)} + onChange={debouncedOnChange} placeholder={placeholder || 'Filter by query'} value={textFieldValue} {...restOfTextFieldProps} diff --git a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextfield.test.tsx b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextfield.test.tsx index fc430e82dd7..82e9a136b18 100644 --- a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextfield.test.tsx +++ b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextfield.test.tsx @@ -12,6 +12,7 @@ const props = { isSearching: false, label: labelVal, onSearch: vi.fn(), + value: '', }; describe('Debounced Search Text Field', () => { diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx index 55d4c031f37..13f40092654 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx @@ -1,7 +1,5 @@ -import { Linode } from '@linode/api-v4/lib/linodes'; -import { Theme } from '@mui/material/styles'; -import useMediaQuery from '@mui/material/useMediaQuery'; import { useTheme } from '@mui/material'; +import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; import { Hidden } from 'src/components/Hidden'; @@ -15,7 +13,10 @@ import { useSpecificTypes } from 'src/queries/types'; import { extendType } from 'src/utilities/extendType'; import { TransferTable } from './TransferTable'; -import { Entity, TransferEntity } from './transferReducer'; + +import type { Entity, TransferEntity } from './transferReducer'; +import type { Linode } from '@linode/api-v4/lib/linodes'; +import type { Theme } from '@mui/material/styles'; interface Props { handleRemove: (linodesToRemove: string[]) => void; @@ -77,6 +78,7 @@ export const LinodeTransferTable = React.memo((props: Props) => { page={pagination.page} pageSize={pagination.pageSize} requestPage={pagination.handlePageChange} + searchText={searchText} toggleSelectAll={toggleSelectAll} > void; + searchText: string; toggleSelectAll: (isToggled: boolean) => void; } @@ -36,6 +37,7 @@ export const TransferTable = React.memo((props: Props) => { page, pageSize, requestPage, + searchText, toggleSelectAll, } = props; @@ -57,6 +59,7 @@ export const TransferTable = React.memo((props: Props) => { label="Search by label" onSearch={handleSearch} placeholder="Search by label" + value={searchText} /> diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx index bb6da070f08..fb7aaa33c32 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/shared/LinodeSelectTable.tsx @@ -140,20 +140,19 @@ export const LinodeSelectTable = (props: Props) => { )} { - if (preselectedLinodeId) { - setPreselectedLinodeId(undefined); - } - setQuery(value ?? ''); - }, - value: preselectedLinodeId ? field.value?.label ?? '' : query, + onSearch={(value) => { + if (preselectedLinodeId) { + setPreselectedLinodeId(undefined); + } + setQuery(value); }} clearable + debounceTime={250} hideLabel isSearching={isFetching} label="Search" placeholder="Search" + value={preselectedLinodeId ? field.value?.label ?? '' : query} /> {matchesMdUp ? ( 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 653a1f3980d..d9db90caf33 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent } from '@testing-library/react'; +import { fireEvent, waitFor } from '@testing-library/react'; import { HttpResponse, http } from 'msw'; import React from 'react'; @@ -89,11 +89,14 @@ describe('SelectLinodePanel (table, desktop)', () => { target: { value: defaultProps.linodes[0].label }, }); - await expect((await findAllByRole('row')).length).toBe(2); - - expect((await findAllByRole('row'))[1]).toHaveTextContent( - defaultProps.linodes[0].label - ); + await waitFor(async () => { + // wait for rows to be rendered + const rows = await findAllByRole('row'); + // Assert that 2 rows are rendered + expect(rows.length).toBe(2); + // Check that the second row has the correct text content + expect(rows[1]).toHaveTextContent(defaultProps.linodes[0].label); + }); }); it('displays the heading, notices and error', () => { @@ -188,13 +191,14 @@ describe('SelectLinodePanel (cards, mobile)', () => { target: { value: defaultProps.linodes[0].label }, }); - await expect( - container.querySelectorAll('[data-qa-selection-card]').length - ).toBe(1); - - expect( - container.querySelectorAll('[data-qa-selection-card]')[0] - ).toHaveTextContent(defaultProps.linodes[0].label); + await waitFor(() => { + expect( + container.querySelectorAll('[data-qa-selection-card]').length + ).toBe(1); + expect( + container.querySelectorAll('[data-qa-selection-card]')[0] + ).toHaveTextContent(defaultProps.linodes[0].label); + }); }); it('prefills the search box when mounted with a selected linode', async () => { diff --git a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx index 7fc4a1a78d0..c9bc7bfd1b7 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/SelectLinodePanel/SelectLinodePanel.tsx @@ -1,4 +1,3 @@ -import { Linode } from '@linode/api-v4/lib/linodes'; import { useMediaQuery } from '@mui/material'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; @@ -8,7 +7,7 @@ import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextFiel import { List } from 'src/components/List'; import { ListItem } from 'src/components/ListItem'; import { Notice } from 'src/components/Notice/Notice'; -import { OrderByProps, sortData } from 'src/components/OrderBy'; +import { sortData } from 'src/components/OrderBy'; import Paginate from 'src/components/Paginate'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { Paper } from 'src/components/Paper'; @@ -21,6 +20,9 @@ import { PowerActionsDialog } from '../../PowerActionsDialogOrDrawer'; import { SelectLinodeCards } from './SelectLinodeCards'; import { SelectLinodeTable } from './SelectLinodeTable'; +import type { Linode } from '@linode/api-v4/lib/linodes'; +import type { OrderByProps } from 'src/components/OrderBy'; + interface Props { disabled?: boolean; error?: string; @@ -138,10 +140,6 @@ export const SelectLinodePanel = (props: Props) => { {!!header ? header : 'Select Linode'} { expand={true} hideLabel label="" + onSearch={setUserSearchText} placeholder="Search" + value={searchText} /> { const { clientAPIKey, lastUpdated, lastUpdatedError, timezone } = props; // Text input for filtering processes by name or user. - const [inputText, setInputText] = React.useState(); + const [inputText, setInputText] = React.useState(); // The selected process row. const [selectedProcess, setSelectedProcess] = React.useState( diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx index 673aa8fb31c..a346b0e241f 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClients.tsx @@ -1,25 +1,16 @@ -import { - ActiveLongviewPlan, - LongviewClient, - LongviewSubscription, -} from '@linode/api-v4/lib/longview/types'; import { isEmpty, pathOr } from 'ramda'; import * as React from 'react'; import { connect } from 'react-redux'; -import { Link, RouteComponentProps } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { compose } from 'recompose'; import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Typography } from 'src/components/Typography'; -import withLongviewClients, { - Props as LongviewProps, -} from 'src/containers/longview.container'; +import withLongviewClients from 'src/containers/longview.container'; import { useAccountSettings } from 'src/queries/account/settings'; import { useGrants, useProfile } from 'src/queries/profile/profile'; -import { State as StatsState } from 'src/store/longviewStats/longviewStats.reducer'; -import { MapState } from 'src/store/types'; import { LongviewPackageDrawer } from '../LongviewPackageDrawer'; import { sumUsedMemory } from '../shared/utilities'; @@ -36,6 +27,16 @@ import { LongviewDeleteDialog } from './LongviewDeleteDialog'; import { LongviewList } from './LongviewList'; import { SubscriptionDialog } from './SubscriptionDialog'; +import type { + ActiveLongviewPlan, + LongviewClient, + LongviewSubscription, +} from '@linode/api-v4/lib/longview/types'; +import type { RouteComponentProps } from 'react-router-dom'; +import type { Props as LongviewProps } from 'src/containers/longview.container'; +import type { State as StatsState } from 'src/store/longviewStats/longviewStats.reducer'; +import type { MapState } from 'src/store/types'; + interface Props { activeSubscription: ActiveLongviewPlan; handleAddClient: () => void; @@ -203,30 +204,32 @@ export const LongviewClients = (props: LongviewClientsCombinedProps) => { Sort by: { handleSortKeyChange(value); }} - size="small" textFieldProps={{ hideLabel: true, }} value={sortOptions.find( (thisOption) => thisOption.value === sortKey )} + disableClearable + fullWidth + label="Sort by" + options={sortOptions} + size="small" /> diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.tsx index 635f6f98052..7347ff8ca95 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsDetail/PlacementGroupsLinodes/PlacementGroupsLinodes.tsx @@ -90,12 +90,11 @@ export const PlacementGroupsLinodes = (props: Props) => { { - setSearchText(value); - }} + clearable debounceTime={250} hideLabel label="Search Linodes" + onSearch={setSearchText} placeholder="Search Linodes" value={searchText} /> diff --git a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx index 8b6dfbdf6ba..096c7885d10 100644 --- a/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx +++ b/packages/manager/src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLanding.tsx @@ -1,15 +1,12 @@ -import CloseIcon from '@mui/icons-material/Close'; import { useMediaQuery, useTheme } from '@mui/material'; import * as React from 'react'; -import { useHistory } from 'react-router-dom'; import { useParams } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { CircleProgress } from 'src/components/CircleProgress'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; 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 { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { Table } from 'src/components/Table'; @@ -174,26 +171,12 @@ export const PlacementGroupsLanding = React.memo(() => { title="Placement Groups" /> - {isFetching && } - - setQuery('')} - size="small" - sx={{ padding: 'unset' }} - > - - - - ), - }} + clearable debounceTime={250} hideLabel + isSearching={isFetching} label="Search" - onChange={(e) => setQuery(e.target.value)} + onSearch={setQuery} placeholder="Search Placement Groups" sx={{ mb: 4 }} value={query} diff --git a/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx b/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx index 71163057cff..e08a0db4129 100644 --- a/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx +++ b/packages/manager/src/features/StackScripts/StackScriptBase/StackScriptBase.tsx @@ -219,6 +219,7 @@ const withStackScriptBase = (options: WithStackScriptBaseOptions) => ( label="Search by Label, Username, or Description" onSearch={this.handleSearch} placeholder="Search by Label, Username, or Description" + value={query ?? ''} /> { label="Filter Subnets by label or id" onSearch={handleSearch} placeholder="Filter Subnets by label or id" + value={subnetsFilterText} />