diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2d1bd208bd..d6508896b7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,106 +12,87 @@ jobs: lint: strategy: matrix: - package: ["linode-manager", "@linode/api-v4", "@linode/validation", "@linode/ui"] + package: + [ + "linode-manager", + "@linode/api-v4", + "@linode/queries", + "@linode/ui", + "@linode/utilities", + "@linode/validation", + ] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v4 - with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - run: yarn --frozen-lockfile - - run: yarn workspace ${{ matrix.package }} run lint + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter ${{ matrix.package }} lint build-validation: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v4 - with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - run: yarn --frozen-lockfile - - run: yarn workspace @linode/validation run build + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter @linode/validation build - uses: actions/upload-artifact@v4 with: name: packages-validation-lib path: packages/validation/lib - publish-validation: - runs-on: ubuntu-latest - if: github.ref == 'refs/heads/master' - needs: build-validation - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - name: packages-validation-lib - path: packages/validation/lib - - uses: JS-DevTools/npm-publish@v1 - id: npm-publish - with: - token: ${{ secrets.NPM_AUTH_TOKEN }} - package: ./packages/validation/package.json - - name: slack-notify - uses: rtCamp/action-slack-notify@master - if: steps.npm-publish.outputs.type != 'none' - env: - SLACK_CHANNEL: api-js-client - SLACK_TITLE: "Linode Validation v${{ steps.npm-publish.outputs.version}}" - SLACK_MESSAGE: ":rocket: Linode Validation Library has been published to NPM: ${{ steps.npm-publish.outputs.old-version }} => ${{ steps.npm-publish.outputs.version }}. View the changelog at https://github.com/linode/manager/blob/master/packages/validation/CHANGELOG.md" - SLACK_USERNAME: npm-bot - SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} - SLACK_ICON_EMOJI: ":package:" - MSG_MINIMAL: true - test-sdk: runs-on: ubuntu-latest needs: build-validation steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v4 - with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - run: yarn --frozen-lockfile + cache: "pnpm" + - run: pnpm install --frozen-lockfile - uses: actions/download-artifact@v4 with: name: packages-validation-lib path: packages/validation/lib - - run: yarn workspace @linode/api-v4 run test + - run: pnpm run --filter @linode/api-v4 test build-sdk: runs-on: ubuntu-latest needs: build-validation steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v4 - with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + cache: "pnpm" - uses: actions/download-artifact@v4 with: name: packages-validation-lib path: packages/validation/lib - - run: yarn --frozen-lockfile - - run: yarn workspace @linode/api-v4 run build + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter @linode/api-v4 build - uses: actions/upload-artifact@v4 with: name: packages-api-v4-lib @@ -122,9 +103,15 @@ jobs: needs: build-sdk steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 - uses: actions/setup-node@v4 with: node-version: "20.17" + cache: "pnpm" + - run: pnpm install --frozen-lockfile # Download the validation and api-v4 artifacts (built packages) - uses: actions/download-artifact@v4 @@ -137,10 +124,10 @@ jobs: path: packages/api-v4/lib # Create an api-v4 tarball - - run: cd packages/api-v4 && npm pack --pack-destination ../../ + - run: cd packages/api-v4 && pnpm pack --pack-destination ../../ # Create an validation tarball - - run: cd packages/validation && npm pack --pack-destination ../../ + - run: cd packages/validation && pnpm pack --pack-destination ../../ # Test @linode/api-v4 as an ES Module - run: mkdir test-sdk-esm && cd test-sdk-esm && npm init es6 -y && npm install ../$(ls ../ | grep "linode-api-v4-.*\.tgz") ../$(ls ../ | grep "linode-validation-.*\.tgz") @@ -157,14 +144,14 @@ jobs: needs: build-sdk steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v4 - with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + cache: "pnpm" - uses: actions/download-artifact@v4 with: name: packages-validation-lib @@ -173,93 +160,148 @@ jobs: with: name: packages-api-v4-lib path: packages/api-v4/lib - - run: yarn --frozen-lockfile - - run: yarn workspace linode-manager run test + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter linode-manager test test-search: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v4 - with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - run: yarn --frozen-lockfile - - run: yarn workspace @linode/search run test + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter @linode/search test test-ui: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: "20.17" + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter @linode/ui test + + test-utilities: + needs: build-sdk + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v4 + cache: "pnpm" + - uses: actions/download-artifact@v4 with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - run: yarn --frozen-lockfile - - run: yarn workspace @linode/ui run test + name: packages-api-v4-lib + path: packages/api-v4/lib + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter @linode/utilities test - typecheck-ui: + test-queries: runs-on: ubuntu-latest needs: build-sdk steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v4 + cache: "pnpm" + - uses: actions/download-artifact@v4 with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - run: yarn --frozen-lockfile - - run: yarn workspace @linode/ui run typecheck + name: packages-api-v4-lib + path: packages/api-v4/lib + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter @linode/queries test - typecheck-manager: + typecheck-ui: runs-on: ubuntu-latest needs: build-sdk steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v4 + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter @linode/ui typecheck + + typecheck-utilities: + runs-on: ubuntu-latest + needs: build-sdk + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - uses: actions/download-artifact@v4 + run_install: false + version: 10 + - uses: actions/setup-node@v4 with: - name: packages-validation-lib - path: packages/validation/lib + node-version: "20.17" + cache: "pnpm" - uses: actions/download-artifact@v4 with: name: packages-api-v4-lib path: packages/api-v4/lib - - run: yarn --frozen-lockfile - - run: yarn workspace linode-manager run typecheck + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter @linode/utilities typecheck - build-manager: + typecheck-queries: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/master' needs: build-sdk steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v4 + cache: "pnpm" + - uses: actions/download-artifact@v4 + with: + name: packages-api-v4-lib + path: packages/api-v4/lib + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter @linode/queries typecheck + + typecheck-manager: + runs-on: ubuntu-latest + needs: build-sdk + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 + - uses: actions/setup-node@v4 with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + node-version: "20.17" + cache: "pnpm" - uses: actions/download-artifact@v4 with: name: packages-validation-lib @@ -268,43 +310,49 @@ jobs: with: name: packages-api-v4-lib path: packages/api-v4/lib - - run: yarn --frozen-lockfile - - run: yarn workspace linode-manager run build - - uses: actions/upload-artifact@v4 - with: - name: packages-manager-build - path: packages/manager/build + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter linode-manager typecheck - publish-sdk: + publish-packages: runs-on: ubuntu-latest if: github.ref == 'refs/heads/master' needs: + - build-sdk + - build-validation - test-sdk - validate-sdk - # If the validation publish failed we could have mismatched versions and a broken JS client - - publish-validation steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 + - uses: actions/setup-node@v4 + with: + node-version: "20.17" + cache: "pnpm" - uses: actions/download-artifact@v4 with: name: packages-api-v4-lib path: packages/api-v4/lib - - uses: JS-DevTools/npm-publish@v1 - id: npm-publish + - uses: actions/download-artifact@v4 with: - token: ${{ secrets.NPM_AUTH_TOKEN }} - package: ./packages/api-v4/package.json + name: packages-validation-lib + path: packages/validation/lib + - run: pnpm install --frozen-lockfile + - run: npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN} + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - run: pnpm publish -r --filter @linode/api-v4 --filter @linode/validation --no-git-checks --access public - name: slack-notify uses: rtCamp/action-slack-notify@master - if: steps.npm-publish.outputs.type != 'none' env: SLACK_CHANNEL: api-js-client - SLACK_TITLE: "Linode JS Client v${{ steps.npm-publish.outputs.version}}" - SLACK_MESSAGE: ":rocket: Linode JS Client has been published to NPM: ${{ steps.npm-publish.outputs.old-version }} => ${{ steps.npm-publish.outputs.version }}. View the changelog at https://github.com/linode/manager/blob/master/packages/api-v4/CHANGELOG.md" + SLACK_TITLE: "Packages published" + SLACK_MESSAGE: ":rocket: Linode packages have been published!" SLACK_USERNAME: npm-bot SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} SLACK_ICON_EMOJI: ":package:" - MSG_MINIMAL: true build-storybook: runs-on: ubuntu-latest @@ -313,14 +361,14 @@ jobs: NODE_OPTIONS: --max-old-space-size=4096 steps: - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v4 - with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + cache: "pnpm" - uses: actions/download-artifact@v4 with: name: packages-validation-lib @@ -329,8 +377,8 @@ jobs: with: name: packages-api-v4-lib path: packages/api-v4/lib - - run: yarn --frozen-lockfile - - run: yarn workspace linode-manager run build-storybook + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter linode-manager build-storybook - uses: actions/upload-artifact@v4 with: name: storybook-build diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml deleted file mode 100644 index 4da3dbe498b..00000000000 --- a/.github/workflows/coverage.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: Code Coverage - -on: [pull_request] - -jobs: - base_branch: - if: github.event.pull_request.draft == false - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.base_ref }} # The base branch of the PR (develop) - - - name: Use Node.js v20.17 LTS - uses: actions/setup-node@v4 - with: - node-version: "20.17" - - - uses: actions/cache@v4 - with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - - name: Install Dependencies - run: yarn --frozen-lockfile - - - name: Build @linode/validation - run: yarn build:validation - - - name: Build @linode/api-v4 - run: yarn build:sdk - - - name: Run Base Branch Coverage - run: yarn coverage:summary - - - name: Write Base Coverage to an Artifact - run: | - coverage_json=$(cat ./packages/manager/coverage/coverage-summary.json) - pct=$(echo "$coverage_json" | jq -r '.total.statements.pct') - echo "$pct" > ref_code_coverage.txt - - - name: Upload Base Coverage Artifact - uses: actions/upload-artifact@v4 - with: - name: ref_code_coverage - path: ref_code_coverage.txt - - current_branch: - # We want to make sure we only run on open PRs (not drafts), but also should run even if the base branch coverage job fails. - # If the base branch coverage job fails to create a report, the current branch coverage job will fail as well, but this may help us debug the CI on the current branch. - if: ${{ always() && github.event.pull_request.draft == false }} - runs-on: ubuntu-latest - needs: base_branch - - steps: - - uses: actions/checkout@v4 - - - name: Use Node.js v20.17 LTS - uses: actions/setup-node@v4 - with: - node-version: "20.17" - - - uses: actions/cache@v4 - with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - - name: Install Dependencies - run: yarn --frozen-lockfile - - - name: Build @linode/validation - run: yarn build:validation - - - name: Build @linode/api-v4 - run: yarn build:sdk - - - name: Run Current Branch Coverage - run: yarn coverage:summary - - - name: Write PR Number to an Artifact - run: | - echo "${{ github.event.number }}" > pr_number.txt - - - name: Write Current Coverage to an Artifact - run: | - coverage_json=$(cat ./packages/manager/coverage/coverage-summary.json) - pct=$(echo "$coverage_json" | jq -r '.total.statements.pct') - echo "$pct" > current_code_coverage.txt - - - name: Upload PR Number Artifact - uses: actions/upload-artifact@v4 - with: - name: pr_number - path: pr_number.txt - - - name: Upload Current Coverage Artifact - uses: actions/upload-artifact@v4 - with: - name: current_code_coverage - path: current_code_coverage.txt diff --git a/.github/workflows/coverage_badge.yml b/.github/workflows/coverage_badge.yml index ca07bfd7f27..96291bdb84e 100644 --- a/.github/workflows/coverage_badge.yml +++ b/.github/workflows/coverage_badge.yml @@ -13,28 +13,28 @@ jobs: - name: Checkout Code uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 + - name: Use Node.js v20.17 LTS uses: actions/setup-node@v4 with: node-version: "20.17" - - - uses: actions/cache@v4 - with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + cache: "pnpm" - name: Install Dependencies - run: yarn --frozen-lockfile + run: pnpm install --frozen-lockfile - name: Build @linode/validation - run: yarn build:validation + run: pnpm build:validation - name: Build @linode/api-v4 - run: yarn build:sdk + run: pnpm build:sdk - name: Run Base Branch Coverage - run: yarn coverage:summary + run: pnpm coverage:summary - name: Generate Coverage Badge uses: jaywcjlove/coverage-badges-cli@7f0781807ef3e7aba97a145beca881d36451b7b7 # v1.1.1 diff --git a/.github/workflows/coverage_comment.yml b/.github/workflows/coverage_comment.yml deleted file mode 100644 index 9c395dd244d..00000000000 --- a/.github/workflows/coverage_comment.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Coverage Comment - -on: - workflow_run: - workflows: ["Code Coverage"] - types: - - completed - -permissions: - pull-requests: write - -jobs: - comment: - if: ${{ github.event.workflow_run.conclusion == 'success' }} - runs-on: ubuntu-latest - - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Use Node.js v20.17 LTS - uses: actions/setup-node@v4 - with: - node-version: "20.17" - - - name: Download PR Number Artifact - uses: dawidd6/action-download-artifact@268677152d06ba59fcec7a7f0b5d961b6ccd7e1e #v2.28.0 - with: - workflow: "coverage.yml" - run_id: ${{ github.event.workflow_run.id }} - name: pr_number - - - name: Download Base Coverage Artifact - uses: dawidd6/action-download-artifact@268677152d06ba59fcec7a7f0b5d961b6ccd7e1e #v2.28.0 - with: - workflow: "coverage.yml" - run_id: ${{ github.event.workflow_run.id }} - name: ref_code_coverage - - - name: Download Current Coverage Artifact - uses: dawidd6/action-download-artifact@268677152d06ba59fcec7a7f0b5d961b6ccd7e1e #v2.28.0 - with: - workflow: "coverage.yml" - run_id: ${{ github.event.workflow_run.id }} - name: current_code_coverage - - - name: Set PR Number Environment Variables - run: | - echo "PR_NUMBER=$(cat pr_number.txt)" >> $GITHUB_ENV - - - name: Generate Coverage Comment - run: | - base_coverage=$(cat ref_code_coverage.txt) - current_coverage=$(cat current_code_coverage.txt) - if (( $(echo "$current_coverage < $base_coverage" | bc -l) )); then - icon="❌" # Error icon - else - icon="✅" # Check mark icon - fi - comment_message="**Coverage Report:** $icon
Base Coverage: $base_coverage%
Current Coverage: $current_coverage%" - echo "Coverage: $comment_message" - echo "$comment_message" > updated_comment.txt - - - name: Post Comment - uses: mshick/add-pr-comment@7c0890544fb33b0bdd2e59467fbacb62e028a096 #v2.8.1 - with: - issue: ${{ env.PR_NUMBER }} - message-path: updated_comment.txt diff --git a/.github/workflows/e2e_schedule_and_push.yml b/.github/workflows/e2e_schedule_and_push.yml index e95da872490..6def67c0a41 100644 --- a/.github/workflows/e2e_schedule_and_push.yml +++ b/.github/workflows/e2e_schedule_and_push.yml @@ -8,6 +8,8 @@ env: CLIENT_ID: ${{ secrets.REACT_APP_CLIENT_ID }} CY_TEST_FAIL_ON_MANAGED: 1 CY_TEST_RESET_PREFERENCES: 1 + CY_TEST_SPLIT_RUN: 1 + CY_TEST_SPLIT_RUN_TOTAL: 4 on: schedule: - cron: "0 13 * * 1-5" @@ -19,37 +21,27 @@ on: jobs: run-cypress-e2e: + name: Cypress Tests (User ${{ matrix.user.index }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: - user: ["USER_1", "USER_2", "USER_3", "USER_4"] + user: + - { index: 1, name: 'USER_1' } + - { index: 2, name: 'USER_2' } + - { index: 3, name: 'USER_3' } + - { index: 4, name: 'USER_4' } steps: - - name: install command line utilities - run: sudo apt-get install -y expect - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + run_install: false + version: 10 - uses: actions/setup-node@v4 with: node-version: "20.17" - - uses: actions/cache@v4 - with: - path: | - node_modules - */*/node_modules - ~/.cache/Cypress - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p - run: | - echo "CYPRESS_RECORD_KEY=${{ secrets.CYPRESS_RECORD_KEY }}" >> $GITHUB_ENV echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV - - run: echo "MANAGER_OAUTH=$USER_1" >> ./packages/manager/.env - if: matrix['user'] == 'USER_1' - - run: echo "MANAGER_OAUTH=$USER_2" >> ./packages/manager/.env - if: matrix['user'] == 'USER_2' - - run: echo "MANAGER_OAUTH=$USER_3" >> ./packages/manager/.env - if: matrix['user'] == 'USER_3' - - run: echo "MANAGER_OAUTH=$USER_4" >> ./packages/manager/.env - if: matrix['user'] == 'USER_4' - run: | echo "REACT_APP_LAUNCH_DARKLY_ID=${{ secrets.REACT_APP_LAUNCH_DARKLY_ID }}" >> ./packages/manager/.env echo "REACT_APP_CLIENT_ID=$CLIENT_ID" >> ./packages/manager/.env @@ -57,9 +49,11 @@ jobs: echo "REACT_APP_API_ROOT=${{ secrets.REACT_APP_API_ROOT }}" >> ./packages/manager/.env echo "REACT_APP_APP_ROOT=${{ secrets.REACT_APP_APP_ROOT }}" >> ./packages/manager/.env echo "REACT_APP_DISABLE_NEW_RELIC=1" >> ./packages/manager/.env - yarn install:all - yarn build - yarn start:manager:ci & + echo "MANAGER_OAUTH=${{ secrets[matrix.user.name] }}" >> ./packages/manager/.env + echo "CY_TEST_SPLIT_RUN_INDEX=${{ matrix.user.index }}" >> ./packages/manager/.env + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter @linode/validation build + - run: pnpm run --filter @linode/api-v4 build - name: Run tests uses: cypress-io/github-action@v6 with: @@ -67,6 +61,8 @@ jobs: wait-on: "http://localhost:3000" wait-on-timeout: 1000 install: false + build: pnpm run build + start: pnpm start:ci browser: chrome - record: true - parallel: true + record: false + parallel: false diff --git a/.gitignore b/.gitignore index 9b96da463da..7e58fb3e68e 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,6 @@ packages/manager/bundle_analyzer_report.html # vitepress docs/.vitepress/cache + +# pnpm store will be generated if you run pnpm install in docker environments +.pnpm-store diff --git a/.husky/pre-commit b/.husky/pre-commit index c799b8b688d..e02c24e2b5c 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -yarn workspaces run precommit +pnpm lint-staged \ No newline at end of file diff --git a/README.md b/README.md index 618cc77f6c0..cc7d036486a 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,13 @@ ## Overview -This repository is home to the Akamai Connected **[Cloud Manager](https://cloud.linode.com)** and related [`@linode/api-v4`](packages/api-v4/), [`@linode/validation`](packages/validation/) and [`@linode/ui`](packages/ui/) Typescript packages. +This repository is home to the Akamai Connected **[Cloud Manager](https://cloud.linode.com)** and related Typescript packages: +- [`@linode/api-v4`](packages/api-v4/) +- [`@linode/queries`](packages/queries/) +- [`@linode/search`](packages/search/) +- [`@linode/ui`](packages/ui/) +- [`@linode/utilities`](packages/utilities/) +- [`@linode/validation`](packages/validation/) ## Developing Locally diff --git a/docker-compose.yml b/docker-compose.yml index 44817bd934b..d7ccac0bb01 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -83,7 +83,7 @@ x-e2e-runners: target: e2e env_file: ./packages/manager/.env volumes: *default-volumes - entrypoint: 'yarn' + entrypoint: 'pnpm' services: # Serves a local instance of Cloud Manager for Cypress to use for its tests. @@ -164,7 +164,7 @@ services: entrypoint: - "/bin/sh" - "-c" - - "caddy reverse-proxy --from $${CYPRESS_BASE_URL} --to $${REVERSE_PROXY_URL} & yarn $0 $@" + - "caddy reverse-proxy --from $${CYPRESS_BASE_URL} --to $${REVERSE_PROXY_URL} > /dev/null 2>&1 & pnpm $0 $@" # Cypress component test runner service. # @@ -191,7 +191,7 @@ services: environment: <<: *default-env MANAGER_OAUTH: ${MANAGER_OAUTH} - entrypoint: ['yarn', 'cy:e2e'] + entrypoint: ['pnpm', 'cy:e2e'] # Component test runner. # Does not require any Cloud Manager environment to run. @@ -201,7 +201,7 @@ services: environment: CY_TEST_DISABLE_RETRIES: ${CY_TEST_DISABLE_RETRIES} CY_TEST_JUNIT_REPORT: ${CY_TEST_JUNIT_REPORT} - entrypoint: ['yarn', 'cy:component:run'] + entrypoint: ['pnpm', 'cy:component:run'] # End-to-end test runner for Cloud's synthetic monitoring tests. # Configured to run against a remote Cloud instance hosted at some URL. @@ -211,4 +211,4 @@ services: environment: <<: *default-env MANAGER_OAUTH: ${MANAGER_OAUTH} - entrypoint: ['yarn', 'cy:e2e'] + entrypoint: ['pnpm', 'cy:e2e'] diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index c64ee516fe9..633804d3a14 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -33,7 +33,7 @@ Feel free to open an issue to report a bug or request a feature. - install it via `brew`: https://github.com/cli/cli#installation or upgrade with `brew upgrade gh` - Once installed, run `gh repo set-default` and pick `linode/manager` (only > 2.21.0) - You can also just create the changeset manually, in this case make sure to use the proper formatting for it. - - Run `yarn changeset`from the root, choose the package to create a changeset for, and provide a description for the change. + - Run `pnpm changeset`from the root, choose the package to create a changeset for, and provide a description for the change. You can either have it committed automatically or do it manually if you need to edit it. - A changeset is optional, but should be included if the PR falls in one of the following categories:
`Added`, `Fixed`, `Changed`, `Removed`, `Tech Stories`, `Tests`, `Upcoming Features` @@ -74,4 +74,4 @@ Break down *additional* things in your PR into multiple PRs (like you would do w ## Docs -To run the docs development server locally, [install Bun](https://bun.sh/) and start the server: `yarn docs`. +To run the docs development server locally, [install Bun](https://bun.sh/) and start the server: `pnpm run docs`. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 960fd65f850..3e36f22e05f 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -10,47 +10,52 @@ 8. Install Node.js 20.17 LTS. We recommend using [Volta](https://volta.sh/): ```bash - - $ curl https://get.volta.sh | bash + curl https://get.volta.sh | bash ## Add volta to your .*rc file, or open a new terminal window. - $ volta install node@20.17 + volta install node@20.17 - $ node --version + node --version ## v20.17.0 - ``` -9. Install the latest version of Yarn: +9. Install pnpm v10 using Volta or view the [pnpm docs](https://pnpm.io/installation) for more installation methods ```bash - $ npm install --global yarn --upgrade - # 1.22.10 + volta install pnpm@10 + + pnpm --version + # 10.2.0 ``` -10. Navigate to the root directory of the repository, then start Cloud Manager and the JS client with `yarn up`. -11. After installation, Cloud Manager should be running at `http://localhost:3000`. +10. Navigate to the root directory of the repository +11. Run `pnpm bootstrap` to install dependencies and perform an initial build of our packages +12. Run `pnpm dev` to start the local development server. Cloud Manager should be running at `http://localhost:3000` ## Serving a production build of Cloud Manager -You can then serve these files however you prefer or use our included local http server. +You can build a production bundle of Cloud Manager and serve it locally. ```bash -yarn install:all +pnpm install + +pnpm run --filter @linode/validation build # build the @linode/validation package + +pnpm run --filter @linode/api-v4 build # build the @linode/api-v4 (it depends on @linode/validation) -yarn workspace linode-manager build +pnpm run --filter linode-manager build # build a production bundle of Cloud Manager -yarn workspace linode-manager run start:ci +pnpm run --filter linode-manager start:ci # start a local http server on http://localhost:3000/ ``` ## Exposing Cloud Manager's dev server to the network By default, Cloud Manager's dev server only listens on `localhost`. If you need to -expose the Vite dev server, you can use the following command. +expose the Vite dev server to all network interfaces, you can use the following command. > **Note**: This is useful for running Cloud Manager's dev server in Docker-like environments ```bash -yarn up:expose +pnpm run up:expose ``` diff --git a/docs/development-guide/01-repository-structure.md b/docs/development-guide/01-repository-structure.md index a8b5caa2c0c..7727baed618 100644 --- a/docs/development-guide/01-repository-structure.md +++ b/docs/development-guide/01-repository-structure.md @@ -8,7 +8,7 @@ The linode/manager repository is a monorepo that houses three packages: The **manager** package is dependent on the **api-v4** package, which is itself dependent on the **validation** package. -The repo has a root level `package.json` which defines project-level scripts, hooks, and dependencies. The code for dependencies shared across projects are hoisted up to the root-level `/node_modules` directory. There is a single `yarn.lock` file for the repo which lives at the root level. +The repo has a root level `package.json` which defines project-level scripts, hooks, and dependencies. The code for dependencies shared across projects are hoisted up to the root-level `/node_modules` directory. There is a single `pnpm-lock.yaml` file for the repo which lives at the root level. Any files relevant to the entire project or repo should be included at the root level. Files belonging to a specific package belong in `/packages/`. @@ -52,7 +52,7 @@ Like api-v4, TypeScript files are compiled to /lib and compiled + minified to in A few notable directories in the root level of the manager package: - **/build** - - where the app is compiled to after running `yarn build` (gitignored) + - where the app is compiled to after running `pnpm build` (gitignored) - **/config** - configuration for unit tests - **/cypress** diff --git a/docs/development-guide/04-component-library.md b/docs/development-guide/04-component-library.md index 5e1ca262d0b..67a2202df6a 100644 --- a/docs/development-guide/04-component-library.md +++ b/docs/development-guide/04-component-library.md @@ -22,9 +22,9 @@ We use [Storybook](https://storybook.js.org/) to document our UI component libra #### Running Storybook Locally -`yarn build-storybook`: builds Storybook as a static web application, with build output located in `/packages/manager/storybook-static`; must be run from `/packages/manager` directory +`pnpm run --filter linode-manager build-storybook`: builds Storybook as a static web application, with build output located in `/packages/manager/storybook-static`; must be run from `/packages/manager` directory -`yarn storybook`: starts the local dev server at `localhost:6006` +`pnpm storybook`: starts the local dev server at `localhost:6006` #### Adding Stories diff --git a/docs/development-guide/05-fetching-data.md b/docs/development-guide/05-fetching-data.md index e6fcd59fc95..7b37b724ead 100644 --- a/docs/development-guide/05-fetching-data.md +++ b/docs/development-guide/05-fetching-data.md @@ -113,7 +113,7 @@ const profileQueries = createQueryKeys('profile', { queryFn: getProfile, queryKey: null, }, -}) +}); export const useProfile = () => useQuery({ @@ -146,7 +146,7 @@ const UsernameDisplay = () => { ## When to use React Query or an api-v4 method directly Because **api-v4** methods don't commit data to a cache, it is acceptable to use **api-v4** methods directly -when performing ***one-time actions*** that do not require any immediate state change in Cloud Manager's UI. +when performing **_one-time actions_** that do not require any immediate state change in Cloud Manager's UI. While use of **api-v4** methods directly are acceptable, use of **React Query** Queries or Mutations are **still prefered** for the benefits described above. @@ -246,7 +246,7 @@ console.log(errorMap); #### Scrolling to errors -For deep forms, we provide a utility that will scroll to the first error encountered within a defined container. We do this to improve error visibility, because the user can be unaware of an error that isn't in the viewport. +For deep forms, we provide a utility that will scroll to the first error encountered within a defined container. We do this to improve error visibility, because the user can be unaware of an error that isn't in the viewport. An error can be a notice (API error) or a Formik field error. In order to implement this often needed functionality, we must declare a form or form container via ref, then pass it to the `scrollErrorIntoViewV2` util (works both for class & functional components). Note: the legacy `scrollErrorIntoView` is deprecated in favor of `scrollErrorIntoViewV2`. @@ -254,10 +254,11 @@ Note: the legacy `scrollErrorIntoView` is deprecated in favor of `scrollErrorInt Since Cloud Manager uses different ways of handling forms and validation, the `scrollErrorIntoViewV2` util should be implemented using the following patterns to ensure consistency. ##### Formik (deprecated) + ```Typescript import * as React from 'react'; -import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; +import { scrollErrorIntoViewV2 } from '@linode/utilities'; export const MyComponent = () => { const formContainerRef = React.useRef(null); @@ -285,10 +286,11 @@ export const MyComponent = () => { ``` ##### React Hook Forms + ```Typescript import * as React from 'react'; -import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; +import { scrollErrorIntoViewV2 } from '@linode/utilities'; export const MyComponent = () => { const formContainerRef = React.useRef(null); @@ -316,10 +318,11 @@ export const MyComponent = () => { ``` ##### Uncontrolled forms + ```Typescript import * as React from 'react'; -import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; +import { scrollErrorIntoViewV2 } from '@linode/utilities'; export const MyComponent = () => { const formContainerRef = React.useRef(null); @@ -343,6 +346,8 @@ export const MyComponent = () => { ``` ### Toast / Event Message Punctuation + **Best practice:** + - If a message is a sentence or a sentence fragment with a subject and a verb, add punctuation. Otherwise, leave punctuation off. -- If a developer notices inconsistencies within files they are already working in, they can progressively fix them. In this case, be prepared to fix any Cypress test failures. \ No newline at end of file +- If a developer notices inconsistencies within files they are already working in, they can progressively fix them. In this case, be prepared to fix any Cypress test failures. diff --git a/docs/development-guide/08-testing.md b/docs/development-guide/08-testing.md index 27ad221daee..8d51f0c4208 100644 --- a/docs/development-guide/08-testing.md +++ b/docs/development-guide/08-testing.md @@ -4,45 +4,37 @@ The unit tests for Cloud Manager are written in Typescript using the [Vitest](https://vitest.dev/) testing framework. Unit tests end with either `.test.tsx` or `.test.ts` file extensions and can be found throughout the codebase. -To run tests, first build the **api-v4** package: +To run tests, first ensure dependencies are installed and packages are built: ```shell -yarn install:all && yarn workspace @linode/api-v4 build +pnpm bootstrap ``` Then you can start the tests: ```shell -yarn test +pnpm test ``` Or you can run the tests in watch mode with: ```shell -yarn test:watch +pnpm test:watch ``` To run a specific file or files in a directory: ```shell -yarn test myFile.test.tsx -yarn test src/some-folder +pnpm test myFile.test.tsx +pnpm test src/some-folder ``` Vitest has built-in pattern matching, so you can also do things like run all tests whose filename contains "Linode" with: ```shell -yarn test linode +pnpm test linode ``` -To run a test in debug mode, add a `debugger` breakpoint inside one of the test cases, then run: - -```shell -yarn workspace linode-manager run test:debug -``` - -Test execution will stop at the debugger statement, and you will be able to use Chrome's normal debugger to step through the tests (open `chrome://inspect/#devices` in Chrome). - ### React Testing Library This library provides a set of tools to render React components from within the Vitest environment. The library's philosophy is that components should be tested as closely as possible to how they are used. @@ -162,9 +154,9 @@ We use [Cypress](https://cypress.io) for end-to-end testing. Test files are foun ### Running End-to-End Tests -1. In one terminal window, run the app with `yarn up`. -2. In another terminal window, run all of the tests with `yarn cy:run`. - * Alternatively, use Cypress's interactive interface with `yarn cy:debug` if you're focused on a single test suite. +1. In one terminal window, run the app with `pnpm dev`. +2. In another terminal window, run all of the tests with `pnpm cy:run`. + * Alternatively, use Cypress's interactive interface with `pnpm cy:debug` if you're focused on a single test suite. #### Configuring End-to-End Tests @@ -188,7 +180,7 @@ Environment variables related to the general operation of the Cloud Manager Cypr | Environment Variable | Description | Example | Default | |----------------------|-------------------------------------------------------------------------------------------------------|--------------|---------------------------------| -| `CY_TEST_SUITE` | Name of the Cloud Manager UI test suite to run. Possible values are `core`, `region`, or `synthetic`. | `region` | Unset; defaults to `core` suite | +| `CY_TEST_SUITE` | Name of the Cloud Manager UI test suite to run. Possible values are `core` or `synthetic`. | `synthetic` | Unset; defaults to `core` suite | | `CY_TEST_TAGS` | Query identifying tests that should run by specifying allowed and disallowed tags. | `method:e2e` | Unset; all tests run by default | ###### Overriding Behavior @@ -197,7 +189,6 @@ These environment variables can be used to override some behaviors of Cloud Mana | Environment Variable | Description | Example | Default | |-------------------------|-------------------------------------------------|-----------|--------------------------------------------| -| `CY_TEST_REGION` | ID of region to test (as used by Linode APIv4). | `us-east` | Unset; regions are selected at random | | `CY_TEST_FEATURE_FLAGS` | JSON string containing feature flag data | `{}` | Unset; feature flag data is not overridden | ###### Run Splitting @@ -243,12 +234,12 @@ Environment variables that can be used to improve test performance in some scena /* this test will not pass on cloud manager. it is only intended to show correct test structure, syntax, and to provide examples of patterns/methods commonly used in the tests */ - + // start of a test block. Multiple tests can be nested within describe('linode landing checks', () => { // hook that runs before each test beforeEach(() => { - // uses factory to build data (factories found in packages/manager/src/factories) + // uses factory to build data (factories found in packages/manager/src/factories) const mockAccountSettings = accountSettingsFactory.build({ managed: false, }); @@ -260,8 +251,8 @@ Environment variables that can be used to improve test performance in some scena }); // start of individual test block it('checks the landng page side menu items', () => { - - /* intercept only once method for when a call happens multiple times + + /* intercept only once method for when a call happens multiple times but you only want to stub it once declared in `/cypress/support/ui/common.ts` */ interceptOnce('GET', '*/profile/preferences*', { linodes_view_style: 'list', @@ -287,7 +278,7 @@ Environment variables that can be used to improve test performance in some scena cy.get(`[data-qa-ip-main]`) // `realHover` and more real event methods from cypress real events plugin .realHover() - .then(() => { + .then(() => { cy.get(`[aria-label="Copy ${ip} to clipboard"]`).should('be.visible'); }); cy.get(`[aria-label="Action menu for Linode ${label}"]`).should('be.visible'); @@ -303,11 +294,11 @@ Environment variables that can be used to improve test performance in some scena ```tsx // stub response syntax: cy.intercept('POST', ‘/path’, {response}) or cy.intercept(‘/path’, (req) => { req.reply({response})}).as('something'); - // edit and end response syntax: + // edit and end response syntax: cy.intercept('GET', ‘/path’, (req) => { req.send({edit: something})}).as('something'); // edit request syntax: cy.intercept('POST', ‘/path’, (req) => { req.body.storyName = 'some name'; req.continue().as('something'); - + // use alias syntax: wait(‘@something’).then({}) ``` diff --git a/docs/development-guide/12-managing-dependencies.md b/docs/development-guide/12-managing-dependencies.md index 029b7a56291..0de01a2478e 100644 --- a/docs/development-guide/12-managing-dependencies.md +++ b/docs/development-guide/12-managing-dependencies.md @@ -1,6 +1,6 @@ # Managing Dependencies -Dependencies are managed with [Yarn](https://yarnpkg.com/). +Dependencies are managed with [pnpm](https://pnpm.io/). ## Installing new dependencies @@ -8,18 +8,18 @@ First, consider if you _definitely need_ to install the dependency. Basic utilit If the library features you are after would require a lot of effort to write and test yourself, installing a well-tested and well-adopted open-source library is a good option. -To install a dependency, simply add the package to the appropriate `package.json` and run `yarn install` from the root level of the repo. Yarn will automatically update `yarn.lock` and add the library code to `node_modules/`. +To install a dependency, simply add the package to the appropriate `package.json` and run `pnpm install` from the root level of the repo. pnpm will automatically update `pnpm-lock.yaml` and add the library code to `node_modules/`. ## Updating dependencies -To update a dependency, simply update its version number in the appropriate `package.json` and run `yarn install` from the root level of the repo. +To update a dependency, simply update its version number in the appropriate `package.json` and run `pnpm install` from the root level of the repo. ### Security patches If a _direct dependency_ gets a security patch, it's usually easy to update it using the instructions above. -If a _sub-dependency_ (dependency of a dependency) gets a security patch, first we must see which of our direct dependencies uses it. Running `yarn why ` and looking through `yarn.lock` is a good way to do this. +If a _sub-dependency_ (dependency of a dependency) gets a security patch, first we must see which of our direct dependencies uses it. Running `pnpm why -r ` and looking through `pnpm-lock.yaml` is a good way to do this. The best case scenario here is that all packages in the dependency tree have been updated to accept the security patch, and we can update the direct dependency using the instructions above. -More often this will not be the case, however, and we'll need to force Yarn to resolve to the patched version using the `resolutions` field in `package.json`. Depending on the situation, you will need to update one or all of the `package.json` files in this repo. +More often this will not be the case, however, and we'll need to force pnpm to resolve to the patched version using the `resolutions` field in `package.json`. Depending on the situation, you will need to update one or all of the `package.json` files in this repo. diff --git a/docs/development-guide/16-design-tokens.md b/docs/development-guide/16-design-tokens.md new file mode 100644 index 00000000000..71225d5bc75 --- /dev/null +++ b/docs/development-guide/16-design-tokens.md @@ -0,0 +1,114 @@ + +# Design Tokens +## Token Usage + +Our design system provides tokens via the `theme.tokens` object, which contains various token categories like spacing, color, typography, etc. These tokens are the building blocks of our design system and should be used consistently throughout the application. + +```tsx +// Accessing spacing tokens directly +theme.tokens.spacing.S16 // "1rem" + +// Accessing typography tokens +theme.tokens.alias.Typography.Heading.Xxl // "800 1.625rem/2rem 'Nunito Sans'" +``` + +### ⚠️ Warning: Global vs. Theme-Sensitive Tokens + +**Do not use `theme.tokens.color` directly in application code.** These are global tokens which are not theme-sensitive and will not respond to theme changes (light/dark mode). + +```tsx +// ❌ Incorrect: Using global color tokens directly + ({ backgroundColor: theme.tokens.color.Neutral[5] })}> + +// ✅ Correct: Using alias (semantic) color tokens + ({ backgroundColor: theme.tokens.alias.Content.Background.Normal })}> + + ({ backgroundColor: theme.tokens.alias.Content.Text.Primary })}> +``` + +### Best Practices for Token Usage + +- Use the most specific token available for your use case. +- Prefer alias or component tokens (which describe purpose) over global tokens (which describe appearance). +- For spacing, use `theme.spacingFunction()` instead of accessing tokens directly when building layouts. + +## Spacing + +### The spacingFunction + +We are transitioning from using MUI's default `theme.spacing` to our own custom `theme.spacingFunction` to ensure consistency with our design token system. + +```tsx +// ❌ Deprecated: Using MUI's default spacing + ({ padding: theme.spacing(2) })}> // 16px (2 × 8px base unit) + +// ✅ Preferred: Using our custom spacingFunction + ({ padding: theme.spacingFunction(16) })}> // "1rem" (S16 token = 16px) +``` + +#### Key Differences + +- **Direct Token Mapping**: Values map directly to our design tokens (S4 = 4px, S8 = 8px, etc.) +- **No Multiplication**: Unlike MUI's spacing that multiplies by a base unit (typically 8px), our spacingFunction maps the value directly to the closest token +- **Rounding Behavior**: For values that don't exactly match a token, the function rounds to the nearest available token + +#### Examples + +```tsx +// Direct token mapping +theme.spacingFunction(4) // "0.25rem" (S4 token = 4px) +theme.spacingFunction(8) // "0.5rem" (S8 token = 8px) +theme.spacingFunction(16) // "1rem" (S16 token = 16px) + +// Rounding behavior +theme.spacingFunction(3) // "0.25rem" (S4 token = 4px, closest to 3) +theme.spacingFunction(5) // "0.25rem" (S4 token = 4px, closest to 5) +theme.spacingFunction(7) // "0.5rem" (S8 token = 8px, closest to 7) + +// Multiple values (CSS shorthand) +theme.spacingFunction(4, 8) // "0.25rem 0.5rem" +theme.spacingFunction(4, 8, 16) // "0.25rem 0.5rem 1rem" +theme.spacingFunction(4, 8, 16, 24) // "0.25rem 0.5rem 1rem 1.5rem" +``` + +#### Migration Guide + +When migrating from `theme.spacing` to `theme.spacingFunction`, use this mapping as a reference: + +| MUI spacing | spacingFunction equivalent | +|-------------|----------------------------| +| `theme.spacing(0.5)` | `theme.spacingFunction(4)` | +| `theme.spacing(1)` | `theme.spacingFunction(8)` | +| `theme.spacing(1.5)` | `theme.spacingFunction(12)` | +| `theme.spacing(2)` | `theme.spacingFunction(16)` | +| `theme.spacing(3)` | `theme.spacingFunction(24)` | +| `theme.spacing(4)` | `theme.spacingFunction(32)` | +| `theme.spacing(5)` | `theme.spacingFunction(40)` | +| `theme.spacing(6)` | `theme.spacingFunction(48)` | +| `theme.spacing(8)` | `theme.spacingFunction(64)` | +| `theme.spacing(9)` | `theme.spacingFunction(72)` | +| `theme.spacing(12)` | `theme.spacingFunction(96)` | + +#### Available Tokens + +Our spacingFunction works with the following design tokens: + +``` +S0: "0" +S2: "0.125rem" (2px) +S4: "0.25rem" (4px) +S6: "0.375rem" (6px) +S8: "0.5rem" (8px) +S12: "0.75rem" (12px) +S16: "1rem" (16px) +S20: "1.25rem" (20px) +S24: "1.5rem" (24px) +S28: "1.75rem" (28px) +S32: "2rem" (32px) +S36: "2.25rem" (36px) +S40: "2.5rem" (40px) +S48: "3rem" (48px) +S64: "4rem" (64px) +S72: "4.5rem" (72px) +S96: "6rem" (96px) +``` diff --git a/package.json b/package.json index fe481632491..63c9c5dd3f5 100644 --- a/package.json +++ b/package.json @@ -3,49 +3,50 @@ "private": true, "license": "Apache-2.0", "devDependencies": { + "concurrently": "9.1.0", "husky": "^9.1.6", "typescript": "^5.7.3", - "vitest": "^3.0.5" + "vitest": "^3.0.7", + "@vitest/ui": "^3.0.7", + "lint-staged": "^15.4.3" }, "scripts": { - "lint": "yarn run eslint . --quiet --ext .js,.ts,.tsx", - "cost-of-modules": "yarn global add cost-of-modules && cost-of-modules --less --no-install --include-dev", - "install:all": "yarn install --frozen-lockfile", - "upgrade:sdk": "yarn workspace @linode/api-v4 version --no-git-tag-version --no-commit-hooks && yarn workspace linode-manager upgrade @linode/api-v4", - "build:sdk": "yarn workspace @linode/api-v4 build", - "build:validation": "yarn workspace @linode/validation build", - "build": "yarn build:validation && yarn build:sdk && yarn workspace linode-manager build", - "build:analyze": "yarn workspace linode-manager build:analyze", - "up": "yarn install:all && yarn build:validation && yarn build:sdk && yarn start:all", - "up:expose": "yarn install:all && yarn build:validation && yarn build:sdk && yarn start:all:expose", - "dev": "yarn install:all && yarn start:all", - "start:all": "concurrently -n api-v4,validation,ui,manager -c blue,yellow,magenta,green \"yarn workspace @linode/api-v4 start\" \"yarn workspace @linode/validation start\" \"yarn workspace @linode/ui start\" \"yarn workspace linode-manager start\"", - "start:all:expose": "concurrently -n api-v4,validation,ui,manager -c blue,yellow,magenta,green \"yarn workspace @linode/api-v4 start\" \"yarn workspace @linode/validation start\" \"yarn workspace @linode/ui start\" \"yarn workspace linode-manager start:expose\"", - "start:manager": "yarn workspace linode-manager start", - "start:manager:ci": "yarn workspace linode-manager start:ci", - "clean": "rm -rf node_modules && rm -rf packages/@linode/api-v4/node_modules && rm -rf packages/manager/node_modules && rm -rf packages/@linode/validation/node_modules", + "lint": "eslint . --quiet --ext .js,.ts,.tsx", + "install:all": "pnpm install --frozen-lockfile", + "build:sdk": "pnpm run --filter @linode/api-v4 build", + "build:validation": "pnpm run --filter @linode/validation build", + "build": "pnpm build:validation && pnpm build:sdk && pnpm --filter linode-manager build", + "build:analyze": "pnpm run --filter linode-manager build:analyze", + "bootstrap": "pnpm install:all && pnpm build:validation && pnpm build:sdk", + "up:expose": "npm_config_package_import_method=clone-or-copy pnpm install:all && pnpm build:validation && pnpm build:sdk && pnpm start:all:expose", + "dev": "concurrently -n api-v4,validation,ui,utilities,queries,manager -c blue,yellow,magenta,cyan,gray,green \"pnpm run --filter @linode/api-v4 start\" \"pnpm run --filter @linode/validation start\" \"pnpm run --filter @linode/ui start\" \"pnpm run --filter @linode/utilities start\" \"pnpm run --filter @linode/queries start\" \"pnpm run --filter linode-manager start\"", + "start:all:expose": "concurrently -n api-v4,validation,ui,utilities,queries,manager -c blue,yellow,magenta,cyan,gray,green \"pnpm run --filter @linode/api-v4 start\" \"pnpm run --filter @linode/validation start\" \"pnpm run --filter @linode/ui start\" \"pnpm run --filter @linode/utilities start\" \"pnpm run --filter @linode/queries start\" \"pnpm run --filter linode-manager start:expose\"", + "start:manager": "pnpm --filter linode-manager start", + "start:manager:ci": "pnpm run --filter linode-manager start:ci", + "docs": "bunx vitepress@1.0.0-rc.44 dev docs", + "storybook": "pnpm run --filter linode-manager storybook", "test": "vitest run", "test:watch": "vitest", - "test:manager": "yarn workspace linode-manager test", - "test:sdk": "yarn workspace @linode/api-v4 test", - "test:search": "yarn workspace @linode/search test", - "test:ui": "yarn workspace @linode/ui test", - "package-versions": "yarn workspace @linode/scripts package-versions", - "storybook": "yarn workspace linode-manager storybook", - "cy:run": "yarn workspace linode-manager cy:run", - "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:component:run": "yarn workspace linode-manager cy:component:run", - "cy:rec-snap": "yarn workspace linode-manager cy:rec-snap", - "changeset": "yarn workspace @linode/scripts changeset", - "generate-changelogs": "yarn workspace @linode/scripts generate-changelogs", - "coverage": "yarn workspace linode-manager coverage", - "coverage:summary": "yarn workspace linode-manager coverage:summary", - "junit:summary": "YARN_SILENT=1 yarn workspace @linode/scripts junit:summary", - "generate-tod": "YARN_SILENT=1 yarn workspace @linode/scripts generate-tod", - "docs": "bunx vitepress@1.0.0-rc.44 dev docs", + "test:manager": "pnpm run --filter linode-manager test", + "test:sdk": "pnpm run --filter @linode/api-v4 test", + "test:search": "pnpm run --filter @linode/search test", + "test:ui": "pnpm run --filter @linode/ui test", + "test:utilities": "pnpm run --filter @linode/utilities test", + "coverage": "pnpm run --filter linode-manager coverage", + "coverage:summary": "pnpm run --filter linode-manager coverage:summary", + "cy:run": "pnpm run --filter linode-manager cy:run", + "cy:e2e": "pnpm run --filter linode-manager cy:e2e", + "cy:ci": "pnpm cy:e2e", + "cy:debug": "pnpm run --filter linode-manager cy:debug", + "cy:component": "pnpm run --filter linode-manager cy:component", + "cy:component:run": "pnpm run --filter linode-manager cy:component:run", + "cy:rec-snap": "pnpm run --filter linode-manager cy:rec-snap", + "changeset": "pnpm run --filter @linode/scripts changeset", + "generate-changelogs": "pnpm run --filter @linode/scripts generate-changelogs", + "package-versions": "pnpm run --filter @linode/scripts package-versions", + "junit:summary": "pnpm run --filter @linode/scripts --silent junit:summary", + "generate-tod": "pnpm run --filter @linode/scripts --silent generate-tod", + "clean": "rm -rf node_modules && rm -rf packages/manager/node_modules && rm -rf packages/api-v4/node_modules && rm -rf packages/validation/node_modules && rm -rf packages/api-v4/lib && rm -rf packages/validation/lib && rm -rf packages/ui/node_modules && rm -rf packages/utilities/node_modules", "prepare": "husky" }, "resolutions": { @@ -55,15 +56,19 @@ "cookie": "^0.7.0", "nanoid": "^3.3.8" }, + "version": "0.0.0", + "volta": { + "node": "20.17.0" + }, "workspaces": { "packages": [ "packages/*", "scripts" ] }, - "version": "0.0.0", - "volta": { - "node": "20.17.0" - }, - "dependencies": {} + "pnpm": { + "onlyBuiltDependencies": [ + "cypress" + ] + } } diff --git a/packages/api-v4/.changeset/README.md b/packages/api-v4/.changeset/README.md index d96182a25b0..fab47f4b66f 100644 --- a/packages/api-v4/.changeset/README.md +++ b/packages/api-v4/.changeset/README.md @@ -1,6 +1,6 @@ # Changesets -This directory gets auto-populated when running `yarn changeset`. +This directory gets auto-populated when running `pnpm changeset`. You can however add your changesets manually as well, knowing that the [TYPE] is limited to the following options `Added`, `Fixed`, `Changed`, `Removed`, `Tech Stories`, `Tests`, `Upcoming Features` and follow this format: ```md @@ -13,6 +13,6 @@ My PR Description ([#`PR number`](`PR link`)) You must commit them to the repo so they can be picked up for the changelog generation. -This directory get wiped out when running `yarn generate-changelog`. +This directory get wiped out when running `pnpm generate-changelog`. See `changeset.mjs` for implementation details. diff --git a/packages/api-v4/CHANGELOG.md b/packages/api-v4/CHANGELOG.md index b03c3100b3d..da1d5adfb86 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,35 @@ +## [2025-03-25] - v0.136.0 + + +### Added: + +- Add and update `/v4beta/nodebalancers` endpoints for NB-VPC Integration ([#11811](https://github.com/linode/manager/pull/11811)) + +### Changed: + +- Add `type` and `lke_cluster` to Nodebalancer interface and `getNodeBalancerBeta` function ([#11653](https://github.com/linode/manager/pull/11653)) +- Make `interface_generation` on `Linode` optional ([#11655](https://github.com/linode/manager/pull/11655)) +- Make `label` field in `CreateFirewallPayload` required ([#11677](https://github.com/linode/manager/pull/11677)) +- Region `Capabilities` type to temporarily include LA Disk Encryption ([#11783](https://github.com/linode/manager/pull/11783)) + +### Tech Stories: + +- Upgrade tsup to 8.4.0 ([#11866](https://github.com/linode/manager/pull/11866)) + +### Upcoming Features: + +- Update region capability and Public Interface object for Linode Interfaces ([#11621](https://github.com/linode/manager/pull/11621)) +- Add the payload type for EditAlertDefinition, API request changes for the user edit functionality ([#11669](https://github.com/linode/manager/pull/11669)) +- Add `getAlertDefinitionByServiceType` in alerts.ts ([#11685](https://github.com/linode/manager/pull/11685)) +- Update Linode Config types for Linode Interfaces ([#11727](https://github.com/linode/manager/pull/11727)) +- DBaaS Advanced Configurations: add `engine_config` to the Database Instance ([#11735](https://github.com/linode/manager/pull/11735)) +- Use different validation schema for creating enterprise LKE cluster ([#11746](https://github.com/linode/manager/pull/11746)) +- Add the 'account_viewer' type to the IAM types. ([#11762](https://github.com/linode/manager/pull/11762)) +- Add `EntityAlertUpdatePayload` cloudpulse types.ts ([#11785](https://github.com/linode/manager/pull/11785)) +- Switch Quota endpoints to use beta API ([#11818](https://github.com/linode/manager/pull/11818)) +- Fix the type of parameter in api in IAM ([#11840](https://github.com/linode/manager/pull/11840)) +- Add optional ipv6 property to VPC entity ([#11852](https://github.com/linode/manager/pull/11852)) + ## [2025-02-25] - v0.135.0 ### Changed: diff --git a/packages/api-v4/README.md b/packages/api-v4/README.md index 28c200284c2..207ccba730c 100644 --- a/packages/api-v4/README.md +++ b/packages/api-v4/README.md @@ -4,20 +4,11 @@ JavaScript client for the [Linode APIv4](https://developers.linode.com/api/v4) ## Installation -``` -$ npm install @linode/api-v4 -``` - -or with yarn: - -``` -$ yarn add @linode/api-v4 -``` - -or with a CDN: - -```js - +```bash +npm install @linode/api-v4 # using npm +yarn add @linode/api-v4 # using yarn +pnpm add @linode/api-v4 # using pnpm +bun add @linode/api-v4 # using bun ``` ## Usage diff --git a/packages/api-v4/package.json b/packages/api-v4/package.json index 6115ffe4554..ac07b4fcfaf 100644 --- a/packages/api-v4/package.json +++ b/packages/api-v4/package.json @@ -1,6 +1,6 @@ { "name": "@linode/api-v4", - "version": "0.135.0", + "version": "0.136.0", "homepage": "https://github.com/linode/manager/tree/develop/packages/api-v4", "bugs": { "url": "https://github.com/linode/manager/issues" @@ -40,18 +40,17 @@ "browser": "./lib/index.global.js", "unpkg": "./lib/index.global.js", "dependencies": { - "@linode/validation": "*", - "axios": "~1.7.4", + "@linode/validation": "workspace:*", + "axios": "~1.8.3", "ipaddr.js": "^2.0.0", "yup": "^1.4.0" }, "scripts": { "start": "concurrently --raw \"tsc -w --preserveWatchOutput\" \"tsup --watch\"", "build": "concurrently --raw \"tsc\" \"tsup\"", - "test": "yarn vitest run", - "lint": "yarn run eslint . --quiet --ext .js,.ts,.tsx", - "typecheck": "tsc --noEmit true --emitDeclarationOnly false", - "precommit": "lint-staged" + "test": "vitest run", + "lint": "eslint . --quiet --ext .js,.ts,.tsx", + "typecheck": "tsc --noEmit true --emitDeclarationOnly false" }, "files": [ "lib" @@ -61,9 +60,8 @@ "concurrently": "^9.0.1", "eslint": "^6.8.0", "eslint-plugin-sonarjs": "^0.5.0", - "lint-staged": "^15.2.9", "prettier": "~2.2.1", - "tsup": "^8.2.4" + "tsup": "^8.4.0" }, "lint-staged": { "*.{ts,tsx,js}": [ diff --git a/packages/api-v4/src/account/types.ts b/packages/api-v4/src/account/types.ts index 7dd8c6ab700..f59f062832d 100644 --- a/packages/api-v4/src/account/types.ts +++ b/packages/api-v4/src/account/types.ts @@ -203,12 +203,14 @@ export type GlobalGrantTypes = | 'add_linodes' | 'add_longview' | 'add_databases' + | 'add_kubernetes' | 'add_nodebalancers' | 'add_stackscripts' | 'add_volumes' | 'add_vpcs' | 'cancel_account' | 'child_account_access' + | 'add_buckets' | 'longview_subscription'; export interface GlobalGrants { diff --git a/packages/api-v4/src/cloudpulse/alerts.ts b/packages/api-v4/src/cloudpulse/alerts.ts index c0eab85d312..e62401bc99c 100644 --- a/packages/api-v4/src/cloudpulse/alerts.ts +++ b/packages/api-v4/src/cloudpulse/alerts.ts @@ -54,7 +54,7 @@ export const getAlertDefinitionByServiceTypeAndId = ( export const editAlertDefinition = ( data: EditAlertDefinitionPayload, serviceType: string, - alertId: string + alertId: number ) => Request( setURL( @@ -72,3 +72,44 @@ export const getNotificationChannels = (params?: Params, filters?: Filter) => setParams(params), setXFilter(filters) ); + +export const getAlertDefinitionByServiceType = (serviceType: string) => + Request>( + setURL( + `${API_ROOT}/monitor/services/${encodeURIComponent( + serviceType + )}/alert-definitions` + ), + setMethod('GET') + ); + +export const addEntityToAlert = ( + serviceType: string, + entityId: string, + data: { 'alert-definition-id': number } +) => + Request<{}>( + setURL( + `${API_ROOT}/monitor/service/${encodeURIComponent( + serviceType + )}/entity/${encodeURIComponent(entityId)}/alert-definition` + ), + setMethod('POST'), + setData(data) + ); + +export const deleteEntityFromAlert = ( + serviceType: string, + entityId: string, + alertId: number +) => + Request<{}>( + setURL( + `${API_ROOT}/monitor/service/${encodeURIComponent( + serviceType + )}/entity/${encodeURIComponent( + entityId + )}/alert-definition/${encodeURIComponent(alertId)}` + ), + setMethod('DELETE') + ); diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 8bbb635e28a..ec6abdaa067 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -9,7 +9,7 @@ export type DimensionFilterOperatorType = | 'startswith' | 'endswith'; export type AlertDefinitionType = 'system' | 'user'; -export type AlertStatusType = 'enabled' | 'disabled'; +export type AlertStatusType = 'enabled' | 'disabled' | 'in progress' | 'failed'; export type CriteriaConditionType = 'ALL'; export type MetricUnitType = | 'number' @@ -308,14 +308,28 @@ export type NotificationChannel = | NotificationChannelPagerDuty; export interface EditAlertDefinitionPayload { + label?: string; + tags?: string[]; + description?: string; entity_ids?: string[]; + severity?: AlertSeverityType; + rule_criteria?: { + rules: MetricCriteria[]; + }; + trigger_conditions?: TriggerCondition; + channel_ids?: number[]; status?: AlertStatusType; } export interface EditAlertPayloadWithService extends EditAlertDefinitionPayload { serviceType: string; - alertId: string; + alertId: number; } export type AlertStatusUpdateType = 'Enable' | 'Disable'; + +export interface EntityAlertUpdatePayload { + entityId: string; + alert: Alert; +} diff --git a/packages/api-v4/src/databases/types.ts b/packages/api-v4/src/databases/types.ts index 72b0b03bc8d..180cf098dc6 100644 --- a/packages/api-v4/src/databases/types.ts +++ b/packages/api-v4/src/databases/types.ts @@ -70,7 +70,26 @@ interface DatabaseHosts { export interface SSLFields { ca_certificate: string; } - +// TODO: This will be changed in the next PR +export interface MySQLAdvancedConfig { + binlog_retention_period?: number; + advanced?: { + connect_timeout?: number; + default_time_zone?: string; + group_concat_max_len?: number; + information_schema_stats_expiry?: number; + innodb_print_all_deadlocks?: boolean; + sql_mode?: string; + }; +} +// TODO: This will be changed in the next PR +export interface PostgresAdvancedConfig { + advanced?: { + max_files_per_process?: number; + timezone?: string; + pg_stat_monitor_enable?: boolean; + }; +} type MemberType = 'primary' | 'failover'; // DatabaseInstance is the interface for the shape of data returned by the /databases/instances endpoint. @@ -99,6 +118,7 @@ export interface DatabaseInstance { updated: string; updates: UpdatesSchedule; version: string; + engine_config?: MySQLAdvancedConfig | PostgresAdvancedConfig; } export type ClusterSize = 1 | 2 | 3; diff --git a/packages/api-v4/src/firewalls/types.ts b/packages/api-v4/src/firewalls/types.ts index 859b3ad9402..4f5eca2dbce 100644 --- a/packages/api-v4/src/firewalls/types.ts +++ b/packages/api-v4/src/firewalls/types.ts @@ -67,14 +67,14 @@ export interface FirewallTemplate { } export interface CreateFirewallPayload { - label?: string; + label: string; tags?: string[]; rules: UpdateFirewallRules; devices?: { linodes?: number[]; nodebalancers?: number[]; interfaces?: number[]; - }; + } | null; } export interface UpdateFirewallPayload { diff --git a/packages/api-v4/src/iam/iam.ts b/packages/api-v4/src/iam/iam.ts index 2a0e70071a0..31c5be6db83 100644 --- a/packages/api-v4/src/iam/iam.ts +++ b/packages/api-v4/src/iam/iam.ts @@ -31,7 +31,7 @@ export const getUserPermissions = (username: string) => */ export const updateUserPermissions = ( username: string, - data: Partial + data: IamUserPermissions ) => Request( setURL( diff --git a/packages/api-v4/src/iam/types.ts b/packages/api-v4/src/iam/types.ts index 0c1ed28bbfd..9f8d7353483 100644 --- a/packages/api-v4/src/iam/types.ts +++ b/packages/api-v4/src/iam/types.ts @@ -16,6 +16,7 @@ export type AccountAccessType = | 'linode_creator' | 'linode_contributor' | 'account_admin' + | 'account_viewer' | 'firewall_creator'; export type RoleType = diff --git a/packages/api-v4/src/linodes/index.ts b/packages/api-v4/src/linodes/index.ts index 76ceac8e155..38625c60acd 100644 --- a/packages/api-v4/src/linodes/index.ts +++ b/packages/api-v4/src/linodes/index.ts @@ -12,4 +12,6 @@ export * from './ips'; export * from './linodes'; +export * from './linode-interfaces'; + export * from './types'; diff --git a/packages/api-v4/src/linodes/types.ts b/packages/api-v4/src/linodes/types.ts index 418d3bd896f..bf83834138a 100644 --- a/packages/api-v4/src/linodes/types.ts +++ b/packages/api-v4/src/linodes/types.ts @@ -1,11 +1,11 @@ import type { Region, RegionSite } from '../regions'; import type { IPAddress, IPRange } from '../networking/types'; -import type { SSHKey } from '../profile/types'; import type { LinodePlacementGroupPayload } from '../placement-groups/types'; import { InferType } from 'yup'; import { CreateLinodeInterfaceSchema, ModifyLinodeInterfaceSchema, + RebuildLinodeSchema, UpdateLinodeInterfaceSettingsSchema, UpgradeToLinodeInterfaceSchema, } from '@linode/validation'; @@ -35,7 +35,7 @@ export interface Linode { region: string; image: string | null; group: string; - interface_generation: InterfaceGenerationType; + interface_generation?: InterfaceGenerationType; // @TODO Linode Interfaces - Remove optionality once fully rolled out ipv4: string[]; ipv6: string | null; label: string; @@ -201,7 +201,23 @@ export interface Interface { ip_ranges?: string[]; } -export type InterfacePayload = Omit; +export interface InterfacePayload { + /** + * Required to specify a VLAN + */ + label?: string | null; + purpose: InterfacePurpose; + /** + * Used for VLAN, but is optional + */ + ipam_address?: string | null; + primary?: boolean; + subnet_id?: number | null; + vpc_id?: number | null; + ipv4?: ConfigInterfaceIPv4; + ipv6?: ConfigInterfaceIPv6; + ip_ranges?: string[] | null; +} export interface ConfigInterfaceOrderPayload { ids: number[]; @@ -226,8 +242,8 @@ export interface Config { created: string; updated: string; initrd: string | null; - // If a Linode is using the new Linode Interfaces, this field will no longer be present. - interfaces?: Interface[]; + // If a Linode is using new Linode Interfaces, the interfaces in the Config object will be returned as null. + interfaces: Interface[] | null; } // ---------------------------------------------------------- @@ -287,17 +303,23 @@ export interface PublicInterfaceData { address: string; primary: boolean; }[]; - // shared: string[]; + shared: { + address: string; + linode_id: number; + }[]; }; ipv6: { - addresses: { + slaac: { address: string; prefix: string; }[]; - // shared: string[]; + shared: { + range: string; + route_target: string | null; + }[]; ranges: { range: string; - route_target: string; + route_target: string | null; }[]; }; } @@ -424,7 +446,7 @@ export interface LinodeConfigCreationData { updatedb_disabled: boolean; distro: boolean; modules_dep: boolean; - network: boolean; + network?: boolean; devtmpfs_automount: boolean; }; root_device: string; @@ -521,7 +543,7 @@ export interface CreateLinodeRequest { * This is used to set the swap disk size for the newly-created Linode. * @default 512 */ - swap_size?: number; + swap_size?: number | null; /** * An Image ID to deploy the Linode Disk from. */ @@ -534,7 +556,7 @@ export interface CreateLinodeRequest { * A list of public SSH keys that will be automatically appended to the root user’s * `~/.ssh/authorized_keys`file when deploying from an Image. */ - authorized_keys?: string[]; + authorized_keys?: string[] | null; /** * If this field is set to true, the created Linode will automatically be enrolled in the Linode Backup service. * This will incur an additional charge. The cost for the Backup service is dependent on the Type of Linode deployed. @@ -543,7 +565,7 @@ export interface CreateLinodeRequest { * * @default false */ - backups_enabled?: boolean; + backups_enabled?: boolean | null; /** * This field is required only if the StackScript being deployed requires input data from the User for successful completion */ @@ -554,29 +576,29 @@ export interface CreateLinodeRequest { * @default true if the Linode is created with an Image or from a Backup. * @default false if using new Linode Interfaces and no interfaces are defined */ - booted?: boolean; + booted?: boolean | null; /** * The Linode’s label is for display purposes only. * If no label is provided for a Linode, a default will be assigned. */ - label?: string; + label?: string | null; /** * An array of tags applied to this object. * * Tags are for organizational purposes only. */ - tags?: string[]; + tags?: string[] | null; /** * If true, the created Linode will have private networking enabled and assigned a private IPv4 address. * @default false */ - private_ip?: boolean; + private_ip?: boolean | null; /** * A list of usernames. If the usernames have associated SSH keys, * the keys will be appended to the root users `~/.ssh/authorized_keys` * file automatically when deploying from an Image. */ - authorized_users?: string[]; + authorized_users?: string[] | null; /** * An array of Network Interfaces to add to this Linode’s Configuration Profile. */ @@ -592,7 +614,7 @@ export interface CreateLinodeRequest { * * Default value on depends on interfaces_for_new_linodes field in AccountSettings object. */ - interface_generation?: InterfaceGenerationType; + interface_generation?: InterfaceGenerationType | null; /** * Default value mirrors network_helper in AccountSettings object. Should only be * present when using Linode Interfaces. @@ -606,7 +628,7 @@ export interface CreateLinodeRequest { /** * An object containing user-defined data relevant to the creation of Linodes. */ - metadata?: UserData; + metadata?: UserData | null; /** * The `id` of the Firewall to attach this Linode to upon creation. */ @@ -614,12 +636,12 @@ export interface CreateLinodeRequest { /** * An object that assigns this the Linode to a placement group upon creation. */ - placement_group?: CreateLinodePlacementGroupPayload; + placement_group?: CreateLinodePlacementGroupPayload | null; /** * A property with a string literal type indicating whether the Linode is encrypted or unencrypted. * @default 'enabled' (if the region supports LDE) */ - disk_encryption?: EncryptionStatus; + disk_encryption?: EncryptionStatus | null; } export interface MigrateLinodeRequest { @@ -646,17 +668,7 @@ export interface LinodeCloneData { disks?: number[]; } -export interface RebuildRequest { - image: string; - root_pass: string; - metadata?: UserData; - authorized_keys?: SSHKey[]; - authorized_users?: string[]; - stackscript_id?: number; - stackscript_data?: any; - booted?: boolean; - disk_encryption?: EncryptionStatus; -} +export type RebuildRequest = InferType; export interface LinodeDiskCreationData { label: string; diff --git a/packages/api-v4/src/nodebalancers/nodebalancer-config-nodes.ts b/packages/api-v4/src/nodebalancers/nodebalancer-config-nodes.ts index 20b9d7df86a..ccff24807b7 100644 --- a/packages/api-v4/src/nodebalancers/nodebalancer-config-nodes.ts +++ b/packages/api-v4/src/nodebalancers/nodebalancer-config-nodes.ts @@ -1,5 +1,5 @@ import { nodeBalancerConfigNodeSchema } from '@linode/validation/lib/nodebalancers.schema'; -import { API_ROOT } from '../constants'; +import { API_ROOT, BETA_API_ROOT } from '../constants'; import Request, { setData, setMethod, setURL } from '../request'; import { ResourcePage as Page } from '../types'; import { @@ -31,6 +31,28 @@ export const getNodeBalancerConfigNodes = ( ) ); +/** + * getNodeBalancerConfigNodesBeta + * + * Returns a paginated list of nodes for the specified NodeBalancer configuration profile from the beta API. + * Note: Returns the vpc_config_id in case of a VPC backend + * + * @param nodeBalancerId { number } The ID of the NodeBalancer the config belongs to. + * @param configId { number } The configuration profile to retrieve nodes for. + */ +export const getNodeBalancerConfigNodesBeta = ( + nodeBalancerId: number, + configId: number +) => + Request>( + setMethod('GET'), + setURL( + `${BETA_API_ROOT}/nodebalancers/${encodeURIComponent( + nodeBalancerId + )}/configs/${encodeURIComponent(configId)}/nodes` + ) + ); + /** * getNodeBalancerConfigNode * @@ -55,6 +77,32 @@ export const getNodeBalancerConfigNode = ( )}` ) ); +/** + * getNodeBalancerConfigNodeBeta + * + * Returns details about a specific node for the given NodeBalancer configuration profile from the beta API. + * + * Note: Returns the vpc_config_id in case of a VPC backend + * + * @param nodeBalancerId { number } The ID of the NodeBalancer the config belongs to. + * @param configId { number } The configuration profile to retrieve nodes for. + * @param nodeId { number } The Node to be retrieved. + */ +export const getNodeBalancerConfigNodeBeta = ( + nodeBalancerId: number, + configId: number, + nodeId: number +) => + Request>( + setMethod('GET'), + setURL( + `${BETA_API_ROOT}/nodebalancers/${encodeURIComponent( + nodeBalancerId + )}/configs/${encodeURIComponent(configId)}/nodes/${encodeURIComponent( + nodeId + )}` + ) + ); /** * createNodeBalancerConfigNode * @@ -95,7 +143,34 @@ export const createNodeBalancerConfigNode = ( ); /** - * createNodeBalancerConfigNode + * createNodeBalancerConfigNodeBeta + * + * Creates a NodeBalancer Node, a backend that can accept traffic for + * this NodeBalancer Config. Nodes are routed requests on the configured port based on their status. + * + * Note: The BETA version accepts a Node's VPC IP address and subnet-id + * + * @param nodeBalancerId { number } The ID of the NodeBalancer the config belongs to. + * @param configId { number } The configuration profile to add a node to. + * @param data + */ +export const createNodeBalancerConfigNodeBeta = ( + nodeBalancerId: number, + configId: number, + data: CreateNodeBalancerConfigNode +) => + Request( + setMethod('POST'), + setURL( + `${BETA_API_ROOT}/nodebalancers/${encodeURIComponent( + nodeBalancerId + )}/configs/${encodeURIComponent(configId)}/nodes` + ), + setData(data, nodeBalancerConfigNodeSchema, mergeAddressAndPort) + ); + +/** + * UpdateNodeBalancerConfigNode * * Updates a backend node for the specified NodeBalancer configuration profile. * @@ -135,6 +210,34 @@ export const updateNodeBalancerConfigNode = ( setData(data, nodeBalancerConfigNodeSchema, mergeAddressAndPort) ); +/** + * UpdateNodeBalancerConfigNodeBeta + * + * Updates a backend node for the specified NodeBalancer configuration profile. + * + * Note: The BETA version accepts a Node's VPC IP address and subnet-id + * + * @param nodeBalancerId { number } The ID of the NodeBalancer the config belongs to. + * @param configId { number } The configuration profile to add a node to. + * @param data + */ +export const updateNodeBalancerConfigNodeBeta = ( + nodeBalancerId: number, + configId: number, + nodeId: number, + data: UpdateNodeBalancerConfigNode +) => + Request( + setMethod('PUT'), + setURL( + `${BETA_API_ROOT}/nodebalancers/${encodeURIComponent( + nodeBalancerId + )}/configs/${encodeURIComponent(configId)}/nodes/${encodeURIComponent( + nodeId + )}` + ), + setData(data, nodeBalancerConfigNodeSchema, mergeAddressAndPort) + ); /** * deleteNodeBalancerConfigNode * diff --git a/packages/api-v4/src/nodebalancers/nodebalancer-configs.ts b/packages/api-v4/src/nodebalancers/nodebalancer-configs.ts index ac81afa6c28..a690d580c41 100644 --- a/packages/api-v4/src/nodebalancers/nodebalancer-configs.ts +++ b/packages/api-v4/src/nodebalancers/nodebalancer-configs.ts @@ -2,7 +2,7 @@ import { createNodeBalancerConfigSchema, UpdateNodeBalancerConfigSchema, } from '@linode/validation/lib/nodebalancers.schema'; -import { API_ROOT } from '../constants'; +import { API_ROOT, BETA_API_ROOT } from '../constants'; import Request, { setData, setMethod, setParams, setURL } from '../request'; import { ResourcePage as Page, Params } from '../types'; import { @@ -75,6 +75,34 @@ export const createNodeBalancerConfig = ( ) ); +/** + * createNodeBalancerConfigBeta + * + * Creates a NodeBalancer Config, which allows the NodeBalancer to accept traffic on a new port. + * You will need to add NodeBalancer Nodes to the new Config before it can actually serve requests. + * + * Note: The BETA version accepts a Node's VPC IP address and subnet-id + * + * @param nodeBalancerId { number } The NodeBalancer to receive the new config. + */ +export const createNodeBalancerConfigBeta = ( + nodeBalancerId: number, + data: CreateNodeBalancerConfig +) => + Request( + setMethod('POST'), + setURL( + `${BETA_API_ROOT}/nodebalancers/${encodeURIComponent( + nodeBalancerId + )}/configs` + ), + setData( + data, + createNodeBalancerConfigSchema, + combineConfigNodeAddressAndPort + ) + ); + /** * updateNodeBalancerConfig * diff --git a/packages/api-v4/src/nodebalancers/nodebalancers.ts b/packages/api-v4/src/nodebalancers/nodebalancers.ts index c3039693740..f9469565dd8 100644 --- a/packages/api-v4/src/nodebalancers/nodebalancers.ts +++ b/packages/api-v4/src/nodebalancers/nodebalancers.ts @@ -2,7 +2,7 @@ import { NodeBalancerSchema, UpdateNodeBalancerSchema, } from '@linode/validation/lib/nodebalancers.schema'; -import { API_ROOT } from '../constants'; +import { API_ROOT, BETA_API_ROOT } from '../constants'; import Request, { setData, setMethod, @@ -45,6 +45,21 @@ export const getNodeBalancer = (nodeBalancerId: number) => setMethod('GET') ); +/** + * getNodeBalancerBeta + * + * Returns detailed information about a single NodeBalancer including type (only available for LKE-E). + * + * @param nodeBalancerId { number } The ID of the NodeBalancer to retrieve. + */ +export const getNodeBalancerBeta = (nodeBalancerId: number) => + Request( + setURL( + `${BETA_API_ROOT}/nodebalancers/${encodeURIComponent(nodeBalancerId)}` + ), + setMethod('GET') + ); + /** * updateNodeBalancer * @@ -80,6 +95,22 @@ export const createNodeBalancer = (data: CreateNodeBalancerPayload) => ) ); +/** + * createNodeBalancerBeta + * + * Add a NodeBalancer to your account using the beta API + */ +export const createNodeBalancerBeta = (data: CreateNodeBalancerPayload) => + Request( + setMethod('POST'), + setURL(`${BETA_API_ROOT}/nodebalancers`), + setData( + data, + NodeBalancerSchema, + combineNodeBalancerConfigNodeAddressAndPort + ) + ); + /** * deleteNodeBalancer * diff --git a/packages/api-v4/src/nodebalancers/types.ts b/packages/api-v4/src/nodebalancers/types.ts index ef1e1b62e4b..2e782011d09 100644 --- a/packages/api-v4/src/nodebalancers/types.ts +++ b/packages/api-v4/src/nodebalancers/types.ts @@ -10,6 +10,15 @@ type UDPStickiness = 'none' | 'session' | 'source_ip'; export type Stickiness = TCPStickiness | UDPStickiness; +type NodeBalancerType = 'common' | 'premium'; + +export interface LKEClusterInfo { + label: string; + id: number; + url: string; + type: 'lkecluster'; +} + export interface NodeBalancer { id: number; label: string; @@ -27,6 +36,12 @@ export interface NodeBalancer { */ client_udp_sess_throttle?: number; region: string; + type?: NodeBalancerType; + /** + * If the NB is associated with a cluster (active or deleted), return its info + * If the NB is not associated with a cluster, return null + */ + lke_cluster?: LKEClusterInfo | null; ipv4: string; ipv6: null | string; created: string; @@ -166,6 +181,7 @@ export interface CreateNodeBalancerConfig { cipher_suite?: 'recommended' | 'legacy' | 'none'; ssl_cert?: string; ssl_key?: string; + nodes?: CreateNodeBalancerConfigNode[]; } export type UpdateNodeBalancerConfig = CreateNodeBalancerConfig; @@ -178,6 +194,7 @@ export interface CreateNodeBalancerConfigNode { */ mode?: NodeBalancerConfigNodeMode; weight?: number; + subnet_id?: number; } export type UpdateNodeBalancerConfigNode = Partial; @@ -191,6 +208,7 @@ export interface NodeBalancerConfigNode { nodebalancer_id: number; status: 'unknown' | 'UP' | 'DOWN'; weight: number; + vpc_config_id?: number | null; } export interface NodeBalancerConfigNodeWithPort extends NodeBalancerConfigNode { @@ -217,4 +235,9 @@ export interface CreateNodeBalancerPayload { configs: CreateNodeBalancerConfig[]; firewall_id?: number; tags?: string[]; + vpc?: { + subnet_id: number; + ipv4_range: string; + ipv6_range?: string; + }[]; } diff --git a/packages/api-v4/src/quotas/quotas.ts b/packages/api-v4/src/quotas/quotas.ts index dfdd5e8eed1..6e790425bb5 100644 --- a/packages/api-v4/src/quotas/quotas.ts +++ b/packages/api-v4/src/quotas/quotas.ts @@ -1,5 +1,5 @@ import { Filter, Params, ResourcePage as Page } from 'src/types'; -import { API_ROOT } from '../constants'; +import { BETA_API_ROOT } from '../constants'; import Request, { setMethod, setParams, setURL, setXFilter } from '../request'; import { Quota, QuotaType, QuotaUsage } from './types'; @@ -12,7 +12,10 @@ import { Quota, QuotaType, QuotaUsage } from './types'; * @param id { number } the quota ID to look up. */ export const getQuota = (type: QuotaType, id: number) => - Request(setURL(`${API_ROOT}/${type}/quotas/${id}`), setMethod('GET')); + Request( + setURL(`${BETA_API_ROOT}/${type}/quotas/${id}`), + setMethod('GET') + ); /** * getQuotas @@ -29,7 +32,7 @@ export const getQuotas = ( filter: Filter = {} ) => Request>( - setURL(`${API_ROOT}/${type}/quotas`), + setURL(`${BETA_API_ROOT}/${type}/quotas`), setMethod('GET'), setXFilter(filter), setParams(params) @@ -45,6 +48,6 @@ export const getQuotas = ( */ export const getQuotaUsage = (type: QuotaType, id: number) => Request( - setURL(`${API_ROOT}/${type}/quotas/${id}/usage`), + setURL(`${BETA_API_ROOT}/${type}/quotas/${id}/usage`), setMethod('GET') ); diff --git a/packages/api-v4/src/quotas/types.ts b/packages/api-v4/src/quotas/types.ts index cc7fb35c9c5..4819a243afa 100644 --- a/packages/api-v4/src/quotas/types.ts +++ b/packages/api-v4/src/quotas/types.ts @@ -45,6 +45,7 @@ export interface Quota { * The region slug to which this limit applies. * * OBJ limits are applied by endpoint, not region. + * This below really just is a `string` type but being verbose helps with reading comprehension. */ region_applied?: Region['id'] | 'global'; @@ -77,8 +78,16 @@ export interface QuotaUsage { /** * The current account usage, measured in units specified by the * `resource_metric` field. + * + * This can be null if the user does not have resources for the given Quota Name. */ - used: number; + used: number | null; } -export type QuotaType = 'linode' | 'lke' | 'object-storage'; +export const quotaTypes = { + linode: 'Linodes', + lke: 'Kubernetes', + 'object-storage': 'Object Storage', +} as const; + +export type QuotaType = keyof typeof quotaTypes; diff --git a/packages/api-v4/src/regions/types.ts b/packages/api-v4/src/regions/types.ts index 9cbb01bc893..dba281ec3f4 100644 --- a/packages/api-v4/src/regions/types.ts +++ b/packages/api-v4/src/regions/types.ts @@ -9,7 +9,8 @@ export type Capabilities = | 'Cloud Firewall' | 'Disk Encryption' | 'Distributed Plans' - | 'Enhanced Interfaces' + | 'LA Disk Encryption' // @TODO LDE: Remove once LDE is fully rolled out in every DC + | 'Linode Interfaces' | 'GPU Linodes' | 'Kubernetes' | 'Kubernetes Enterprise' diff --git a/packages/api-v4/src/vpcs/types.ts b/packages/api-v4/src/vpcs/types.ts index cf15d1112dd..28ce606fdc8 100644 --- a/packages/api-v4/src/vpcs/types.ts +++ b/packages/api-v4/src/vpcs/types.ts @@ -1,3 +1,11 @@ +interface VPCIPv6 { + range?: string; +} + +interface CreateVPCIPV6 extends VPCIPv6 { + allocation_class?: string; +} + export interface VPC { id: number; label: string; @@ -6,12 +14,14 @@ export interface VPC { subnets: Subnet[]; created: string; updated: string; + ipv6?: VPCIPv6[]; } export interface CreateVPCPayload { label: string; description?: string; region: string; + ipv6?: CreateVPCIPV6[]; subnets?: CreateSubnetPayload[]; } diff --git a/packages/manager/.changeset/README.md b/packages/manager/.changeset/README.md index d96182a25b0..fab47f4b66f 100644 --- a/packages/manager/.changeset/README.md +++ b/packages/manager/.changeset/README.md @@ -1,6 +1,6 @@ # Changesets -This directory gets auto-populated when running `yarn changeset`. +This directory gets auto-populated when running `pnpm changeset`. You can however add your changesets manually as well, knowing that the [TYPE] is limited to the following options `Added`, `Fixed`, `Changed`, `Removed`, `Tech Stories`, `Tests`, `Upcoming Features` and follow this format: ```md @@ -13,6 +13,6 @@ My PR Description ([#`PR number`](`PR link`)) You must commit them to the repo so they can be picked up for the changelog generation. -This directory get wiped out when running `yarn generate-changelog`. +This directory get wiped out when running `pnpm generate-changelog`. See `changeset.mjs` for implementation details. diff --git a/packages/manager/.eslintrc.cjs b/packages/manager/.eslintrc.cjs index 07bb61bc97c..2ad77920b50 100644 --- a/packages/manager/.eslintrc.cjs +++ b/packages/manager/.eslintrc.cjs @@ -1,3 +1,4 @@ +/* eslint-disable sonarjs/no-duplicate-string */ module.exports = { env: { browser: true, @@ -22,7 +23,6 @@ module.exports = { 'build', 'storybook-static', '.storybook', - 'e2e/core', 'public', '!.eslintrc.js', ], @@ -84,6 +84,58 @@ module.exports = { 'testing-library/await-async-query': 'off', }, }, + { + env: { + 'cypress/globals': true, + node: true, + }, + // cypress/e2e/core files have had --fix applied, so enforce error level to maintain code quality + files: ['cypress/e2e/core/**'], + rules: { + '@typescript-eslint/consistent-type-imports': 'error', + 'perfectionist/sort-array-includes': 'error', + 'perfectionist/sort-classes': 'error', + 'perfectionist/sort-enums': 'error', + 'perfectionist/sort-exports': 'error', + 'perfectionist/sort-imports': [ + 'error', + { + 'custom-groups': { + type: { + react: ['react', 'react-*'], + src: ['src*'], + }, + value: { + src: ['src/**/*'], + }, + }, + groups: [ + ['builtin', 'libraries', 'external'], + ['src', 'internal'], + ['parent', 'sibling', 'index'], + 'object', + 'unknown', + [ + 'type', + 'internal-type', + 'parent-type', + 'sibling-type', + 'index-type', + ], + ], + 'newlines-between': 'always', + }, + ], + 'perfectionist/sort-interfaces': 'error', + 'perfectionist/sort-jsx-props': 'error', + 'perfectionist/sort-map-elements': 'error', + 'perfectionist/sort-named-exports': 'error', + 'perfectionist/sort-named-imports': 'error', + 'perfectionist/sort-object-types': 'error', + 'perfectionist/sort-objects': 'error', + 'perfectionist/sort-union-types': 'error', + }, + }, // restrict usage of react-router-dom during migration to tanstack/react-router // TODO: TanStack Router - remove this override when migration is complete { @@ -91,9 +143,11 @@ module.exports = { // for each new features added to the migration router, add its directory here 'src/features/Betas/**/*', 'src/features/Domains/**/*', + 'src/features/Firewalls/**/*', 'src/features/Images/**/*', 'src/features/Longview/**/*', 'src/features/PlacementGroups/**/*', + 'src/features/StackScripts/**/*', 'src/features/Volumes/**/*', ], rules: { @@ -137,11 +191,24 @@ module.exports = { 'Please use useOrderV2 hook for components being migrated to TanStack Router.', name: 'src/components/OrderBy', }, + { + importNames: ['Prompt'], + message: + 'Please use the TanStack useBlocker hook for components/features being migrated to TanStack Router.', + name: 'src/components/Prompt/Prompt', + }, ], }, ], }, }, + // Apply `no-createLinode` rule to `cypress` related files only. + { + files: ['cypress/**'], + rules: { + '@linode/cloud-manager/no-createLinode': 'error', + }, + }, ], parser: '@typescript-eslint/parser', // Specifies the ESLint parser parserOptions: { @@ -173,7 +240,7 @@ module.exports = { ], rules: { '@linode/cloud-manager/deprecate-formik': 'warn', - '@linode/cloud-manager/no-custom-fontWeight': 'error', + '@linode/cloud-manager/no-createLinode': 'off', '@typescript-eslint/consistent-type-imports': 'warn', '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', @@ -256,6 +323,12 @@ module.exports = { 'Please use Typography component from @linode/ui instead of @mui/material', name: '@mui/material', }, + { + importNames: ['Link'], + message: + 'Please use the Link component from src/components/Link instead of react-router-dom', + name: 'react-router-dom', + }, ], 'no-restricted-syntax': [ 'error', diff --git a/packages/manager/.storybook/main.ts b/packages/manager/.storybook/main.ts index ea63a35c53e..09b512e1a99 100644 --- a/packages/manager/.storybook/main.ts +++ b/packages/manager/.storybook/main.ts @@ -47,13 +47,6 @@ const config: StorybookConfig = { }, async viteFinal(config) { return mergeConfig(config, { - base: './', - resolve: { - preserveSymlinks: true, - }, - define: { - 'process.env': {}, - }, optimizeDeps: { include: [ '@storybook/react', diff --git a/packages/manager/CHANGELOG.md b/packages/manager/CHANGELOG.md index c422e9236ae..48eee929b5f 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,11 +4,169 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [2025-03-25] - v1.138.0 + + +### Added: + +- LKE UI updates for checkout bar & NodeBalancer Details summary ([#11653](https://github.com/linode/manager/pull/11653)) +- Link to Linode's Firewall in Linode Entity Details ([#11736](https://github.com/linode/manager/pull/11736)) +- Logic to redirect invalid paths to home page of `/metrics` & `/alerts/definitions` url ([#11837](https://github.com/linode/manager/pull/11837)) +- Tags in Volume Create Flow ([#11696](https://github.com/linode/manager/pull/11696)) + +### Changed: + +- Copy in Node Pool resize, autoscale, and recycle CTAs ([#11664](https://github.com/linode/manager/pull/11664)) +- Make "Public" checkbox default-checked in OAuth App creation form ([#11681](https://github.com/linode/manager/pull/11681)) +- Improve error handling for KubeConfig download during cluster provisioning ([#11683](https://github.com/linode/manager/pull/11683)) +- Update copy for LKE ACL section ([#11746](https://github.com/linode/manager/pull/11746)) +- Update copy for LKE Recycle, Upgrade Version, and Delete Pool modals ([#11775](https://github.com/linode/manager/pull/11775)) +- Update RegionSelect placement group tooltiptext copy ([#11791](https://github.com/linode/manager/pull/11791)) +- Enhance MUI Adornments: Unify Theme for Autocomplete and TextField Components via InputBase Styling ([#11807](https://github.com/linode/manager/pull/11807)) +- Update main search to use new API search implementation for large accounts ([#11819](https://github.com/linode/manager/pull/11819)) +- Update styles to CSD for create menu ([#11821](https://github.com/linode/manager/pull/11821)) +- Bucket create `Label` to `Bucket Name` ([#11877](https://github.com/linode/manager/pull/11877)) +- Account for `LA Disk Encryption` region capability when checking if region supports Disk Encryption ([#11833](https://github.com/linode/manager/pull/11833)) +- Account for whether region supports LDE when determining tooltip display for unencrypted linodes & node pools ([#11833](https://github.com/linode/manager/pull/11833)) + +### Fixed: + +- Document titles of ACPL with appropriate keyword ([#11662](https://github.com/linode/manager/pull/11662)) +- Missing disabled treatment and notices on several create flows for restricted users (#11674, #11687, #11672, #11700) +- Node Pools CTA buttons on small screens ([#11701](https://github.com/linode/manager/pull/11701)) +- 404 cluster endpoint errors on Linode details page for non-LKE Linodes ([#11714](https://github.com/linode/manager/pull/11714)) +- Mobile primary nav height ([#11723](https://github.com/linode/manager/pull/11723)) +- RTX 6000 plans showing up in LKE UI ([#11731](https://github.com/linode/manager/pull/11731)) +- Authentication Provider Selection Card UI regression ([#11732](https://github.com/linode/manager/pull/11732)) +- Unresponsive show details button for selected Stackscript ([#11765](https://github.com/linode/manager/pull/11765)) +- Linodes from distributed regions appearing in Create flow Backups & Clone tab ([#11767](https://github.com/linode/manager/pull/11767)) +- Confusing wording on DBaaS suspend dialog ([#11769](https://github.com/linode/manager/pull/11769)) +- Incorrect helper text in `Add an SSH Key` Drawer ([#11771](https://github.com/linode/manager/pull/11771)) +- Linode Backups Drawer style regressions ([#11776](https://github.com/linode/manager/pull/11776)) +- NodeBalancer Create Summary broken dividers and spacing ([#11779](https://github.com/linode/manager/pull/11779)) +- Disable Firewall Selection in Linode Clone ([#11784](https://github.com/linode/manager/pull/11784)) +- Incorrect default color shown in Avatar color picker ([#11787](https://github.com/linode/manager/pull/11787)) +- PaginationFooter page size select ([#11798](https://github.com/linode/manager/pull/11798)) +- `Add an SSH Key` button spacing ([#11800](https://github.com/linode/manager/pull/11800)) +- Hide VPC Section from Linode Create Clone Tab ([#11805](https://github.com/linode/manager/pull/11805)) +- Minor spacing inconsistencies throughout LKE ([#11827](https://github.com/linode/manager/pull/11827)) +- Storybook not rendering due to crypto.randomUUID not being available in Storybook context ([#11835](https://github.com/linode/manager/pull/11835)) +- Show details button misalignment for selected StackScript ([#11838](https://github.com/linode/manager/pull/11838)) +- Navigation for metrics and alerts under Monitor at `PrimaryNav.tsx` ([#11869](https://github.com/linode/manager/pull/11869)) + +### Removed: + +- Rate limits table from Object Storage details drawer ([#11848](https://github.com/linode/manager/pull/11848)) +- Move `capitalize` utility and `useInterval` hook to `@linode/utilities` package ([#11666](https://github.com/linode/manager/pull/11666)) +- Migrate utilities from `manager` to `utilities` package ([#11711](https://github.com/linode/manager/pull/11711)) +- Migrate ErrorState to ui package ([#11718](https://github.com/linode/manager/pull/11718)) +- Migrate utilities from `manager` to `utilities` package - pt2 ([#11733](https://github.com/linode/manager/pull/11733)) +- Migrate hooks from `manager` to `utilities` package ([#11770](https://github.com/linode/manager/pull/11770)) +- Move linodes-related queries and dependencies to shared packages ([#11774](https://github.com/linode/manager/pull/11774)) +- Migrate utilities from `manager` to `utilities` package - pt3 ([#11778](https://github.com/linode/manager/pull/11778)) +- Migrate Drawer to ui package ([#11789](https://github.com/linode/manager/pull/11789)) +- Migrate ActionsPanel to ui package ([#11810](https://github.com/linode/manager/pull/11810)) +- Unnecessary styled component from Linode Detail summary ([#11820](https://github.com/linode/manager/pull/11820)) +- Move volumes-related queries and dependencies to shared `queries` package ([#11843](https://github.com/linode/manager/pull/11843)) +- Move the entire `sort-by.ts` (excluding sortByUTFDate) to `utilities` package ([#11846](https://github.com/linode/manager/pull/11846)) +- Migrate hooks from `manager` to `utilities` package ([#11850](https://github.com/linode/manager/pull/11850)) +- Migrate utilities from `manager` to `utilities` package - pt4 ([#11859](https://github.com/linode/manager/pull/11859)) +- Code coverage comparison jobs ([#11879](https://github.com/linode/manager/pull/11879)) + +### Tech Stories: + +- Refactor the Linode Rebuild dialog ([#11629](https://github.com/linode/manager/pull/11629)) +- Refactor CreateFirewallDrawer to use `react-hook-form` ([#11677](https://github.com/linode/manager/pull/11677)) +- Upgrade to MUI v6 ([#11688](https://github.com/linode/manager/pull/11688)) +- Migrate Firewalls feature to Tanstack routing ([#11704](https://github.com/linode/manager/pull/11704)) +- Upgrade to 4.0.0 Design Tokens - New Spacing & Badge Tokens ([#11757](https://github.com/linode/manager/pull/11757)) +- Update jspdf dependencies to resolve DOMPurify dependabot alert ([#11768](https://github.com/linode/manager/pull/11768)) +- Upgrade Shiki to 3.1.0 ([#11772](https://github.com/linode/manager/pull/11772)) +- Move @vitest/ui to monorepo root dependency ([#11755](https://github.com/linode/manager/pull/11755)) +- Upgrade vitest and @vitest/ui to 3.0.7 ([#11755](https://github.com/linode/manager/pull/11755)) +- Update react-vnc to 3.0.7 ([#11758](https://github.com/linode/manager/pull/11758)) +- Restrict direct imports of Link from `react-router-dom` ([#11801](https://github.com/linode/manager/pull/11801)) +- Refactor Stackscripts routing (Tanstack) ([#11806](https://github.com/linode/manager/pull/11806)) +- Update main search to not depend on `recompose` library ([#11819](https://github.com/linode/manager/pull/11819)) +- Remedy canvg dependency vulnerability ([#11839](https://github.com/linode/manager/pull/11839)) +- Improve type-safety of Linode Create flow form ([#11847](https://github.com/linode/manager/pull/11847)) +- Upgrade Vite to 6.2.2 ([#11866](https://github.com/linode/manager/pull/11866)) +- Upgrade tsx to 4.19.3 ([#11866](https://github.com/linode/manager/pull/11866)) +- Add MSW crud support for new Linode Interface endpoints ([#11875](https://github.com/linode/manager/pull/11875)) +- Upgrade Storybook to 8.6.7 ([#11876](https://github.com/linode/manager/pull/11876)) + +### Tests: + +- Add Cypress integration test to enable Linode Managed ([#10806](https://github.com/linode/manager/pull/10806)) +- Improve Cypress test VLAN handling ([#11362](https://github.com/linode/manager/pull/11362)) +- Add Cypress test for Service Transfers fetch error ([#11607](https://github.com/linode/manager/pull/11607)) +- Add Cypress tests for restricted user Linode create flow ([#11663](https://github.com/linode/manager/pull/11663)) +- Add test for ACLP Create Alerts ([#11670](https://github.com/linode/manager/pull/11670)) +- Add Cypress test for Image create page for restricted users ([#11705](https://github.com/linode/manager/pull/11705)) +- Configure caddy to ignore test output ([#11706](https://github.com/linode/manager/pull/11706)) +- Add Cypress test for ACLP edit functionality of user defined alert ([#11719](https://github.com/linode/manager/pull/11719)) +- Fix CloudPulse test failures triggered by new notice ([#11728](https://github.com/linode/manager/pull/11728)) +- Remove Cypress test assertion involving Login app text ([#11737](https://github.com/linode/manager/pull/11737)) +- Add Cypress test for Volume create page for restricted users ([#11743](https://github.com/linode/manager/pull/11743)) +- Delete region test suite ([#11780](https://github.com/linode/manager/pull/11780)) +- Add Cypress test for LKE create page for restricted users ([#11793](https://github.com/linode/manager/pull/11793)) +- Fix bug in Edit User alert ([#11822](https://github.com/linode/manager/pull/11822)) +- Fix VPC test failures when factory default region does not exist ([#11862](https://github.com/linode/manager/pull/11862)) +- Add unit tests for `sortByUTFDate` utility ([#11846](https://github.com/linode/manager/pull/11846)) +- Fix Google Pay test failures when using Braintree sandbox environment (#11863) +- Apply new custom eslint rule and lint files (#11689, #11722, #11730, #11756, #11766, #11814) + + +### Upcoming Features: + +- Build new Quotas Controls ([#11647](https://github.com/linode/manager/pull/11647)) +- Add Linode Interfaces Table to the Linode Details page ([#11655](https://github.com/linode/manager/pull/11655)) +- Add final copy and docs links for LKE-E ([#11664](https://github.com/linode/manager/pull/11664)) +- Truncate long usernames and emails in IAM users table and details page ([#11668](https://github.com/linode/manager/pull/11668)) +- Fix filtering in IAM users table ([#11668](https://github.com/linode/manager/pull/11668)) +- Add ability to edit alerts for CloudPulse User Alerts ([#11669](https://github.com/linode/manager/pull/11669)) +- Add ability to create Firewalls from templates ([#11678](https://github.com/linode/manager/pull/11678)) +- Add CloudPulse AlertReusableComponent, utils, and queries for contextual view ([#11685](https://github.com/linode/manager/pull/11685)) +- Filter regions by supported region ids - `getSupportedRegionIds` in CloudPulse alerts ([#11692](https://github.com/linode/manager/pull/11692)) +- Add new tags filter in the resources section of CloudPulse Alerts ([#11693](https://github.com/linode/manager/pull/11693)) +- Fix LKE cluster table sorting when LKE-E beta endpoint is used ([#11714](https://github.com/linode/manager/pull/11714)) +- Hide GPU plans tab for LKE-E ([#11726](https://github.com/linode/manager/pull/11726)) +- Hide Networking sections from Linode Configurations page for Linodes with new interfaces ([#11727](https://github.com/linode/manager/pull/11727)) +- Add table components to CloudPulse Alert Information contextual view ([#11734](https://github.com/linode/manager/pull/11734)) +- Add DBaaS Advanced Configurations initial set up (new tab, drawer) ([#11735](https://github.com/linode/manager/pull/11735)) +- Add Interface type to Linode Entity Detail ([#11736](https://github.com/linode/manager/pull/11736)) +- Add support for `nodebalancerVPC` feature flag for NodeBalancer-VPC integration ([#11738](https://github.com/linode/manager/pull/11738)) +- Fix LKE-E provisioning placeholder when filtering by status ([#11745](https://github.com/linode/manager/pull/11745)) +- Enable ACL by default for LKE-E clusters ([#11746](https://github.com/linode/manager/pull/11746)) +- Improve UX of CloudPulse Alerts create flow and resources section ([#11748](https://github.com/linode/manager/pull/11748)) +- Update IAM assigned roles and entities table and refine styles for IAM permissions component. ([#11762](https://github.com/linode/manager/pull/11762)) +- Enhance UI for Cloudpulse Alerting: Notifications, Metric Limits, and Dimensions ([#11773](https://github.com/linode/manager/pull/11773)) +- Ability to add and remove Linode interfaces ([#11782](https://github.com/linode/manager/pull/11782)) +- Add Confirmation Dialog when toggling an entity’s alert for CloudPulse Alerting ([#11785](https://github.com/linode/manager/pull/11785)) +- Update warnings and actions for LKE-E VPCs ([#11786](https://github.com/linode/manager/pull/11786)) +- Support Linode Interface Account Setting on Linode Create Flow ([#11788](https://github.com/linode/manager/pull/11788)) +- Request for Quota increase modal ([#11792](https://github.com/linode/manager/pull/11792)) +- Disable query to get Linode's firewalls for Linodes using new interfaces in LinodeEntityDetail ([#11796](https://github.com/linode/manager/pull/11796)) +- Update navigation for CloudPulse Metrics to `/metrics` and CloudPulse Alerts to `/alerts` ([#11803](https://github.com/linode/manager/pull/11803)) +- Add Upgrade Interfaces dialog for Linodes using legacy Configuration Profile Interfaces ([#11808](https://github.com/linode/manager/pull/11808)) +- Disable Akamai App Platform beta for LKE-E clusters on create flow ([#11809](https://github.com/linode/manager/pull/11809)) +- Handle errors while enabling and disabling alerts in Monitor at `AlertListTable.tsx` ([#11813](https://github.com/linode/manager/pull/11813)) +- Set `refetchInterval` for 2 mins in CloudPulse alert queries ([#11815](https://github.com/linode/manager/pull/11815)) +- Add resources selection limitation in CloudPulse Alerting resources section for create and edit flows ([#11823](https://github.com/linode/manager/pull/11823)) +- Remove `sxEndIcon` prop from Add Metric, Dimension Filter and Notification Channel buttons ([#11825](https://github.com/linode/manager/pull/11825)) +- Add query to update roles in IAM ([#11840](https://github.com/linode/manager/pull/11840)) +- Add a new drawer for changing role flow in IAM ([#11840](https://github.com/linode/manager/pull/11840)) +- Initial support for VPCs using Linode Interfaces on the Linode create flow ([#11847](https://github.com/linode/manager/pull/11847)) +- Restrict enable/disable actions in CloudPulse Alerts action menu based on alert status ([#11860](https://github.com/linode/manager/pull/11860)) +- Remove toggle in the 'Add A User' drawer and default to limited access for users for IAM ([#11870](https://github.com/linode/manager/pull/11870)) +- Update LKE-E flows to account for LDE status at LA launch ([#11880](https://github.com/linode/manager/pull/11880)) + + ## [2025-02-27] - v1.137.2 ### Fixed: -- Disk Encryption logic preventing Linode deployment in distributed regions ([#11760](https://github.com/linode/manager/pull/11760) +- Disk Encryption logic preventing Linode deployment in distributed regions ([#11760](https://github.com/linode/manager/pull/11760)) ## [2025-02-25] - v1.137.1 diff --git a/packages/manager/Dockerfile b/packages/manager/Dockerfile index df11017306b..d56a7a2fb2e 100644 --- a/packages/manager/Dockerfile +++ b/packages/manager/Dockerfile @@ -32,7 +32,7 @@ RUN apt-get update \ && rm -rf /var/cache/apt/* \ && rm -rf /var/lib/apt/lists/* \ && apt-get clean -CMD yarn start:manager:ci +CMD pnpm start:manager:ci # e2e-build # diff --git a/packages/manager/cypress/component/components/password-input.spec.tsx b/packages/manager/cypress/component/components/password-input.spec.tsx index 1313a09052c..7f6855497d9 100644 --- a/packages/manager/cypress/component/components/password-input.spec.tsx +++ b/packages/manager/cypress/component/components/password-input.spec.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { checkComponentA11y } from 'support/util/accessibility'; import { componentTests, visualTests } from 'support/util/components'; -import PasswordInput from 'src/components/PasswordInput/PasswordInput'; +import { PasswordInput } from 'src/components/PasswordInput/PasswordInput'; const fakePassword = 'this is a password'; const props = { diff --git a/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx b/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx index 5cedacb9062..2b036c5b1ae 100644 --- a/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx +++ b/packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx @@ -393,220 +393,226 @@ const testDiscardOutboundRuleDragViaKeyboard = () => { .should('have.attr', 'aria-disabled', 'true'); }; -componentTests('Firewall Rules Table', (mount) => { - /** - * Keyboard keys used to perform interactions with rows in the Firewall Rules table: - * - Press `Space/Enter` key once to activate keyboard sensor on the selected row. - * - Use `Up/Down` arrow keys to move the row up or down. - * - Press `Space/Enter` key again to drop the focused row. - * - Press `Esc` key to discard drag and drop operation. - * - * Confirms: - * - All keyboard interactions on Firewall Rules table rows work as expected for - * both normal (no vertical scrollbar) and smaller window sizes (with vertical scrollbar). - * - `CustomKeyboardSensor` works as expected. - * - All Mouse interactions on Firewall Rules table rows work as expected. - */ - describe('Keyboard and Mouse Drag and Drop Interactions', () => { - describe('Normal window (no vertical scrollbar)', () => { - beforeEach(() => { - cy.viewport(1536, 960); - }); - - describe('Inbound Rules:', () => { +componentTests( + 'Firewall Rules Table', + (mount) => { + /** + * Keyboard keys used to perform interactions with rows in the Firewall Rules table: + * - Press `Space/Enter` key once to activate keyboard sensor on the selected row. + * - Use `Up/Down` arrow keys to move the row up or down. + * - Press `Space/Enter` key again to drop the focused row. + * - Press `Esc` key to discard drag and drop operation. + * + * Confirms: + * - All keyboard interactions on Firewall Rules table rows work as expected for + * both normal (no vertical scrollbar) and smaller window sizes (with vertical scrollbar). + * - `CustomKeyboardSensor` works as expected. + * - All Mouse interactions on Firewall Rules table rows work as expected. + */ + describe('Keyboard and Mouse Drag and Drop Interactions', () => { + describe('Normal window (no vertical scrollbar)', () => { beforeEach(() => { - mount( - - ); - verifyFirewallWithRules({ - includeInbound: true, - includeOutbound: false, - }); + cy.viewport(1536, 960); }); - it('should move Inbound rule rows using keyboard interaction', () => { - testMoveInboundRuleRowsViaKeyboard(); - }); + describe('Inbound Rules:', () => { + beforeEach(() => { + mount( + + ); + verifyFirewallWithRules({ + includeInbound: true, + includeOutbound: false, + }); + }); - it('should cancel the Inbound rules drag operation with the keyboard `Esc` key', () => { - testDiscardInboundRuleDragViaKeyboard(); - }); + it('should move Inbound rule rows using keyboard interaction', () => { + testMoveInboundRuleRowsViaKeyboard(); + }); - it('should move Inbound rules rows using mouse interaction', () => { - // Drag the 1st row rule to 2nd row position. - dragRowToPositionViaMouse(inboundAriaLabel, 1, 2); - - // Verify the order and labels in the 1st, 2nd, and 3rd rows. - verifyTableRowOrder(inboundAriaLabel, [ - inboundRule2, - inboundRule1, - inboundRule3, - ]); - - // Drag the 3rd row rule to 2nd row position. - dragRowToPositionViaMouse(inboundAriaLabel, 3, 2); - - // Verify the order and labels in the 1st, 2nd, and 3rd rows. - verifyTableRowOrder(inboundAriaLabel, [ - inboundRule2, - inboundRule3, - inboundRule1, - ]); - - // Drag the 3rd row rule to 1st position. - dragRowToPositionViaMouse(inboundAriaLabel, 3, 1); - - // Verify the order and labels in the 1st, 2nd, and 3rd rows. - verifyTableRowOrder(inboundAriaLabel, [ - inboundRule1, - inboundRule2, - inboundRule3, - ]); - }); - }); + it('should cancel the Inbound rules drag operation with the keyboard `Esc` key', () => { + testDiscardInboundRuleDragViaKeyboard(); + }); - describe('Outbound Rules:', () => { - beforeEach(() => { - mount( - - ); - verifyFirewallWithRules({ - includeInbound: false, - includeOutbound: true, + it('should move Inbound rules rows using mouse interaction', () => { + // Drag the 1st row rule to 2nd row position. + dragRowToPositionViaMouse(inboundAriaLabel, 1, 2); + + // Verify the order and labels in the 1st, 2nd, and 3rd rows. + verifyTableRowOrder(inboundAriaLabel, [ + inboundRule2, + inboundRule1, + inboundRule3, + ]); + + // Drag the 3rd row rule to 2nd row position. + dragRowToPositionViaMouse(inboundAriaLabel, 3, 2); + + // Verify the order and labels in the 1st, 2nd, and 3rd rows. + verifyTableRowOrder(inboundAriaLabel, [ + inboundRule2, + inboundRule3, + inboundRule1, + ]); + + // Drag the 3rd row rule to 1st position. + dragRowToPositionViaMouse(inboundAriaLabel, 3, 1); + + // Verify the order and labels in the 1st, 2nd, and 3rd rows. + verifyTableRowOrder(inboundAriaLabel, [ + inboundRule1, + inboundRule2, + inboundRule3, + ]); }); }); - it('should move Outbound rule rows using keyboard interaction', () => { - testMoveOutboundRulesViaKeyboard(); - }); + describe('Outbound Rules:', () => { + beforeEach(() => { + mount( + + ); + verifyFirewallWithRules({ + includeInbound: false, + includeOutbound: true, + }); + }); - it('should cancel the Outbound rules drag operation with the keyboard `Esc` key', () => { - testDiscardOutboundRuleDragViaKeyboard(); - }); + it('should move Outbound rule rows using keyboard interaction', () => { + testMoveOutboundRulesViaKeyboard(); + }); - it('should move Outbound rules rows using mouse interaction', () => { - // Drag the 1st row rule to 2nd row position. - dragRowToPositionViaMouse(outboundAriaLabel, 1, 2); - - // Verify the labels in the 1st, 2nd, and 3rd rows. - verifyTableRowOrder(outboundAriaLabel, [ - outboundRule2, - outboundRule1, - outboundRule3, - ]); - - // Drag the 3rd row rule to 2nd row position. - dragRowToPositionViaMouse(outboundAriaLabel, 3, 2); - - // Verify the order and labels in the 1st, 2nd, and 3rd rows. - verifyTableRowOrder(outboundAriaLabel, [ - outboundRule2, - outboundRule3, - outboundRule1, - ]); - - // Drag the 3rd row rule to 1st position. - dragRowToPositionViaMouse(outboundAriaLabel, 3, 1); - - // Verify the order and labels in the 1st, 2nd, and 3rd rows. - verifyTableRowOrder(outboundAriaLabel, [ - outboundRule1, - outboundRule2, - outboundRule3, - ]); - }); - }); - }); + it('should cancel the Outbound rules drag operation with the keyboard `Esc` key', () => { + testDiscardOutboundRuleDragViaKeyboard(); + }); - describe('Window with vertical scrollbar', () => { - beforeEach(() => { - // Browser window with vertical scroll bar enabled (smaller screens). - cy.viewport(800, 400); - cy.window().should('have.property', 'innerWidth', 800); - cy.window().should('have.property', 'innerHeight', 400); + it('should move Outbound rules rows using mouse interaction', () => { + // Drag the 1st row rule to 2nd row position. + dragRowToPositionViaMouse(outboundAriaLabel, 1, 2); + + // Verify the labels in the 1st, 2nd, and 3rd rows. + verifyTableRowOrder(outboundAriaLabel, [ + outboundRule2, + outboundRule1, + outboundRule3, + ]); + + // Drag the 3rd row rule to 2nd row position. + dragRowToPositionViaMouse(outboundAriaLabel, 3, 2); + + // Verify the order and labels in the 1st, 2nd, and 3rd rows. + verifyTableRowOrder(outboundAriaLabel, [ + outboundRule2, + outboundRule3, + outboundRule1, + ]); + + // Drag the 3rd row rule to 1st position. + dragRowToPositionViaMouse(outboundAriaLabel, 3, 1); + + // Verify the order and labels in the 1st, 2nd, and 3rd rows. + verifyTableRowOrder(outboundAriaLabel, [ + outboundRule1, + outboundRule2, + outboundRule3, + ]); + }); + }); }); - describe('Inbound Rules:', () => { + describe('Window with vertical scrollbar', () => { beforeEach(() => { - mount( - - ); - verifyFirewallWithRules({ - includeInbound: true, - includeOutbound: false, - isSmallViewport: true, - }); + // Browser window with vertical scroll bar enabled (smaller screens). + cy.viewport(800, 400); + cy.window().should('have.property', 'innerWidth', 800); + cy.window().should('have.property', 'innerHeight', 400); }); - it('should move Inbound rule rows using keyboard interaction', () => { - testMoveInboundRuleRowsViaKeyboard(); - }); + describe('Inbound Rules:', () => { + beforeEach(() => { + mount( + + ); + verifyFirewallWithRules({ + includeInbound: true, + includeOutbound: false, + isSmallViewport: true, + }); + }); - it('should cancel the Inbound rules drag operation with the keyboard `Esc` key', () => { - testDiscardInboundRuleDragViaKeyboard(); - }); - }); + it('should move Inbound rule rows using keyboard interaction', () => { + testMoveInboundRuleRowsViaKeyboard(); + }); - describe('Outbound Rules:', () => { - beforeEach(() => { - mount( - - ); - verifyFirewallWithRules({ - includeInbound: false, - includeOutbound: true, - isSmallViewport: true, + it('should cancel the Inbound rules drag operation with the keyboard `Esc` key', () => { + testDiscardInboundRuleDragViaKeyboard(); }); }); - it('should move Outbound rule rows using keyboard interaction', () => { - testMoveOutboundRulesViaKeyboard(); - }); + describe('Outbound Rules:', () => { + beforeEach(() => { + mount( + + ); + verifyFirewallWithRules({ + includeInbound: false, + includeOutbound: true, + isSmallViewport: true, + }); + }); + + it('should move Outbound rule rows using keyboard interaction', () => { + testMoveOutboundRulesViaKeyboard(); + }); - it('should cancel the Outbound rules drag operation with the keyboard `Esc` key', () => { - testDiscardOutboundRuleDragViaKeyboard(); + it('should cancel the Outbound rules drag operation with the keyboard `Esc` key', () => { + testDiscardOutboundRuleDragViaKeyboard(); + }); }); }); }); - }); -}); + }, + { + useTanstackRouter: true, + } +); diff --git a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts index 6211b624c99..728ea5695d7 100644 --- a/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-cancellation.spec.ts @@ -2,23 +2,17 @@ * @file Integration tests for Cloud Manager account cancellation flows. */ -import { profileFactory } from 'src/factories/profile'; -import { accountFactory } from 'src/factories/account'; -import { - mockGetAccount, - mockCancelAccount, - mockCancelAccountError, -} from 'support/intercepts/account'; import { cancellationDataLossWarning, - cancellationPaymentErrorMessage, cancellationDialogTitle, + cancellationPaymentErrorMessage, } from 'support/constants/account'; import { - CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, - PARENT_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, - PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, -} from 'src/features/Account/constants'; + mockCancelAccount, + mockCancelAccountError, + mockGetAccount, +} from 'support/intercepts/account'; +import { mockWebpageUrl } from 'support/intercepts/general'; import { mockGetProfile } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { @@ -26,8 +20,16 @@ import { randomPhrase, randomString, } from 'support/util/random'; + +import { accountFactory } from 'src/factories/account'; +import { profileFactory } from 'src/factories/profile'; +import { + CHILD_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, + PARENT_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, + PROXY_USER_CLOSE_ACCOUNT_TOOLTIP_TEXT, +} from 'src/features/Account/constants'; + import type { CancelAccount } from '@linode/api-v4'; -import { mockWebpageUrl } from 'support/intercepts/general'; describe('Account cancellation', () => { /* @@ -130,7 +132,8 @@ describe('Account cancellation', () => { // Enter account cancellation comments, click "Close Account" again, // and this time mock a successful account cancellation response. mockCancelAccount(mockCancellationResponse).as('cancelAccount'); - cy.contains('Comments (optional)').click().type(cancellationComments); + cy.contains('Comments (optional)').click(); + cy.focused().type(cancellationComments); ui.button .findByTitle('Close Account') @@ -412,7 +415,8 @@ describe('Parent/Child account cancellation', () => { // Enter account cancellation comments, click "Close Account" again, // and this time mock a successful account cancellation response. mockCancelAccount(mockCancellationResponse).as('cancelAccount'); - cy.contains('Comments (optional)').click().type(cancellationComments); + cy.contains('Comments (optional)').click(); + cy.focused().type(cancellationComments); ui.button .findByTitle('Close Account') diff --git a/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts b/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts new file mode 100644 index 00000000000..ba7e837cebc --- /dev/null +++ b/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts @@ -0,0 +1,181 @@ +/** + * @file Integration tests for Cloud Manager account enable Linode Managed flows. + */ + +import { + visitUrlWithManagedDisabled, + visitUrlWithManagedEnabled, +} from 'support/api/managed'; +import { + linodeEnabledMessageText, + linodeManagedStateMessageText, +} from 'support/constants/account'; +import { + mockEnableLinodeManaged, + mockEnableLinodeManagedError, + mockGetAccount, +} from 'support/intercepts/account'; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { mockGetProfile } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; +import { chooseRegion } from 'support/util/regions'; + +import { accountFactory } from 'src/factories/account'; +import { linodeFactory } from 'src/factories/linodes'; +import { profileFactory } from 'src/factories/profile'; + +import type { Linode } from '@linode/api-v4'; + +describe('Account Linode Managed', () => { + /* + * - Confirms that a user can add linode managed from the Account Settings page. + * - Confirms that user is told about the Managed price. + * - Confirms that Cloud Manager displays the Managed state. + */ + it('users can enable Linode Managed', () => { + const mockAccount = accountFactory.build(); + const mockProfile = profileFactory.build({ + restricted: false, + username: 'mock-user', + }); + const mockLinodes = new Array(5).fill(null).map( + (item: null, index: number): Linode => { + return linodeFactory.build({ + label: `Linode ${index}`, + region: chooseRegion().id, + }); + } + ); + + mockGetLinodes(mockLinodes).as('getLinodes'); + mockGetAccount(mockAccount).as('getAccount'); + mockGetProfile(mockProfile).as('getProfile'); + mockEnableLinodeManaged().as('enableLinodeManaged'); + + // Navigate to Account Settings page, click "Add Linode Managed" button. + visitUrlWithManagedDisabled('/account/settings'); + cy.wait(['@getAccount', '@getProfile', '@getLinodes']); + + ui.button + .findByTitle('Add Linode Managed') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.dialog + .findByTitle('Just to confirm...') + .should('be.visible') + .within(() => { + cy.get('h6') + .invoke('text') + .then((text) => { + console.log(`h6 text: ${text.trim()}`); + expect(text.trim()).to.equal( + linodeEnabledMessageText(mockLinodes.length) + ); + }); + + // Confirm that submit button is enabled. + ui.button + .findByTitle('Add Linode Managed') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.wait('@enableLinodeManaged'); + + // Confirm that Cloud Manager displays a notice about Linod managed is enabled. + cy.findByText(linodeManagedStateMessageText, { exact: false }).should( + 'be.visible' + ); + }); + + /* + * - Confirms Cloud Manager behavior when a restricted user attempts to enable Linode Managed. + * - Confirms that API error response message is displayed in confirmation dialog. + */ + it('restricted users cannot enable Managed', () => { + const mockAccount = accountFactory.build(); + const mockProfile = profileFactory.build({ + restricted: true, + username: 'mock-restricted-user', + }); + const errorMessage = 'Unauthorized'; + + mockGetLinodes([]); + mockGetAccount(mockAccount).as('getAccount'); + mockGetProfile(mockProfile).as('getProfile'); + mockEnableLinodeManagedError(errorMessage, 403).as('enableLinodeManaged'); + + // Navigate to Account Settings page, click "Add Linode Managed" button. + visitUrlWithManagedDisabled('/account/settings'); + cy.wait(['@getAccount', '@getProfile']); + + ui.button + .findByTitle('Add Linode Managed') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.dialog + .findByTitle('Just to confirm...') + .should('be.visible') + .within(() => { + cy.get('h6') + .invoke('text') + .then((text) => { + expect(text.trim()).to.equal(linodeEnabledMessageText(0)); + }); + // Confirm that submit button is enabled. + ui.button + .findByTitle('Add Linode Managed') + .should('be.visible') + .should('be.enabled') + .click(); + cy.wait('@enableLinodeManaged'); + // Confirm that Cloud Manager displays a notice about Linode managed is unauthorized. + cy.findByText(errorMessage, { exact: false }).should('be.visible'); + }); + }); + + /* + * - Confirms that a user can aonly cancel Linode Managed by opening a support ticket. + * - Confirms that user will be redirected to the creating support ticket page. + */ + it('users can only open a support ticket to cancel Linode Managed', () => { + const mockAccount = accountFactory.build(); + const mockProfile = profileFactory.build({ + restricted: false, + username: 'mock-user', + }); + + mockGetAccount(mockAccount).as('getAccount'); + mockGetProfile(mockProfile).as('getProfile'); + + // Navigate to Account Settings page. + visitUrlWithManagedEnabled('/account/settings'); + cy.wait(['@getAccount', '@getProfile']); + + // Enable button should not exist for users that already enabled Linode Managed. + cy.findByText('Add Linode Managed').should('not.exist'); + cy.findByText(linodeManagedStateMessageText, { exact: false }).should( + 'be.visible' + ); + + // Navigate to the 'Open a Support Ticket' page. + cy.findByText('Support Ticket').should('be.visible').click(); + cy.url().should('endWith', '/support/tickets'); + + // Confirm that title and category are related to cancelling Linode Managed. + cy.findByLabelText('Title (required)').should( + 'have.value', + 'Cancel Linode Managed' + ); + + cy.findByLabelText('What is this regarding?').should( + 'have.value', + 'General/Account/Billing' + ); + }); +}); diff --git a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts index 60bd4626fee..b53a18ef7d0 100644 --- a/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-login-history.spec.ts @@ -2,16 +2,17 @@ * @file Integration tests for Cloud Manager account login history flows. */ -import { profileFactory } from 'src/factories'; -import { accountLoginFactory } from 'src/factories/accountLogin'; -import { formatDate } from 'src/utilities/formatDate'; -import { mockGetAccountLogins } from 'support/intercepts/account'; -import { mockGetProfile } from 'support/intercepts/profile'; import { - loginHelperText, loginEmptyStateMessageText, + loginHelperText, } from 'support/constants/account'; +import { mockGetAccountLogins } from 'support/intercepts/account'; +import { mockGetProfile } from 'support/intercepts/profile'; + +import { profileFactory } from 'src/factories'; +import { accountLoginFactory } from 'src/factories/accountLogin'; import { PARENT_USER } from 'src/features/Account/constants'; +import { formatDate } from 'src/utilities/formatDate'; describe('Account login history', () => { /* @@ -22,18 +23,18 @@ describe('Account login history', () => { */ it('users can view the login history table', () => { const mockProfile = profileFactory.build({ - username: 'mock-user', restricted: false, user_type: 'default', + username: 'mock-user', }); const mockFailedLogin = accountLoginFactory.build({ + restricted: true, status: 'failed', username: 'mock-restricted-user', - restricted: true, }); const mockSuccessfulLogin = accountLoginFactory.build({ - status: 'successful', restricted: false, + status: 'successful', }); mockGetProfile(mockProfile).as('getProfile'); @@ -95,9 +96,9 @@ describe('Account login history', () => { */ it('restricted child users cannot view login history', () => { const mockProfile = profileFactory.build({ - username: 'mock-child-user', restricted: true, user_type: 'child', + username: 'mock-child-user', }); mockGetProfile(mockProfile).as('getProfile'); @@ -121,9 +122,9 @@ describe('Account login history', () => { */ it('unrestricted child users can view login history', () => { const mockProfile = profileFactory.build({ - username: 'mock-child-user', restricted: false, user_type: 'child', + username: 'mock-child-user', }); mockGetProfile(mockProfile).as('getProfile'); @@ -144,9 +145,9 @@ describe('Account login history', () => { */ it('restricted users cannot view login history', () => { const mockProfile = profileFactory.build({ - username: 'mock-restricted-user', restricted: true, user_type: 'default', + username: 'mock-restricted-user', }); mockGetProfile(mockProfile).as('getProfile'); @@ -172,19 +173,19 @@ describe('Account login history', () => { */ it('shows each login in the Login History landing page as expected', () => { const mockProfile = profileFactory.build({ - username: 'mock-user', restricted: false, user_type: 'default', + username: 'mock-user', }); const mockFailedLogin = accountLoginFactory.build({ + restricted: false, status: 'failed', username: 'mock-user-failed', - restricted: false, }); const mockSuccessfulLogin = accountLoginFactory.build({ + restricted: false, status: 'successful', username: 'mock-user-successful', - restricted: false, }); mockGetProfile(mockProfile).as('getProfile'); diff --git a/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts b/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts index ba00f881332..9029596acb4 100644 --- a/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts +++ b/packages/manager/cypress/e2e/core/account/account-maintenance.spec.ts @@ -1,7 +1,8 @@ import { mockGetMaintenance } from 'support/intercepts/account'; -import { accountMaintenanceFactory } from 'src/factories'; import { parseCsv } from 'support/util/csv'; +import { accountMaintenanceFactory } from 'src/factories'; + describe('Maintenance', () => { /* * - Confirm user can navigate to account maintenance page via user menu. @@ -152,9 +153,9 @@ describe('Maintenance', () => { (maintenance) => ({ entity_label: maintenance.entity.label, entity_type: maintenance.entity.type, - type: maintenance.type, - status: maintenance.status, reason: maintenance.reason, + status: maintenance.status, + type: maintenance.type, }) ); @@ -172,9 +173,9 @@ describe('Maintenance', () => { (entry: any) => ({ entity_label: entry['Entity Label'], entity_type: entry['Entity Type'], - type: entry['Type'], - status: entry['Status'], reason: entry['Reason'], + status: entry['Status'], + type: entry['Type'], }) ); @@ -202,9 +203,9 @@ describe('Maintenance', () => { (maintenance) => ({ entity_label: maintenance.entity.label, entity_type: maintenance.entity.type, - type: maintenance.type, - status: maintenance.status, reason: maintenance.reason, + status: maintenance.status, + type: maintenance.type, }) ); @@ -224,9 +225,9 @@ describe('Maintenance', () => { (entry: any) => ({ entity_label: entry['Entity Label'], entity_type: entry['Entity Type'], - type: entry['Type'], - status: entry['Status'], reason: entry['Reason'], + status: entry['Status'], + type: entry['Type'], }) ); diff --git a/packages/manager/cypress/e2e/core/account/betas.spec.ts b/packages/manager/cypress/e2e/core/account/betas.spec.ts index f47545cf792..292449a4cab 100644 --- a/packages/manager/cypress/e2e/core/account/betas.spec.ts +++ b/packages/manager/cypress/e2e/core/account/betas.spec.ts @@ -3,8 +3,8 @@ */ import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { ui } from 'support/ui'; import { mockGetUserPreferences } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; // TODO Delete feature flag mocks when feature flag is removed. beforeEach(() => { diff --git a/packages/manager/cypress/e2e/core/account/display-settings.spec.ts b/packages/manager/cypress/e2e/core/account/display-settings.spec.ts index 49eb9c30630..64f950dd149 100644 --- a/packages/manager/cypress/e2e/core/account/display-settings.spec.ts +++ b/packages/manager/cypress/e2e/core/account/display-settings.spec.ts @@ -1,13 +1,15 @@ -import { Profile } from '@linode/api-v4'; import { profileFactory } from '@src/factories'; -import { mockGetProfile } from 'support/intercepts/profile'; import { getProfile } from 'support/api/account'; -import { interceptGetProfile } from 'support/intercepts/profile'; import { mockUpdateUsername } from 'support/intercepts/account'; +import { interceptGetProfile } from 'support/intercepts/profile'; +import { mockGetProfile } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { randomString } from 'support/util/random'; + import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; +import type { Profile } from '@linode/api-v4'; + const verifyUsernameAndEmail = ( mockRestrictedProxyProfile: Profile, tooltip: string, @@ -68,8 +70,8 @@ describe('Display Settings', () => { cy.findByLabelText('Username') .should('be.visible') .should('have.value', username) - .clear() - .type(newUsername); + .clear(); + cy.focused().type(newUsername); ui.button .findByTitle('Update Username') @@ -89,9 +91,9 @@ describe('Display Settings', () => { it('disables username/email fields for restricted proxy user', () => { const mockRestrictedProxyProfile = profileFactory.build({ - username: 'restricted-proxy-user', - user_type: 'proxy', restricted: true, + user_type: 'proxy', + username: 'restricted-proxy-user', }); verifyUsernameAndEmail( @@ -103,8 +105,8 @@ describe('Display Settings', () => { it('disables username/email fields for unrestricted proxy user', () => { const mockUnrestrictedProxyProfile = profileFactory.build({ - username: 'unrestricted-proxy-user', user_type: 'proxy', + username: 'unrestricted-proxy-user', }); verifyUsernameAndEmail( @@ -116,9 +118,9 @@ describe('Display Settings', () => { it('disables username/email fields for regular restricted user', () => { const mockRegularRestrictedProfile = profileFactory.build({ - username: 'regular-restricted-user', - user_type: 'default', restricted: true, + user_type: 'default', + username: 'regular-restricted-user', }); verifyUsernameAndEmail( diff --git a/packages/manager/cypress/e2e/core/account/email-bounce.spec.ts b/packages/manager/cypress/e2e/core/account/email-bounce.spec.ts index 7e8443fe8ad..668d8726631 100644 --- a/packages/manager/cypress/e2e/core/account/email-bounce.spec.ts +++ b/packages/manager/cypress/e2e/core/account/email-bounce.spec.ts @@ -2,27 +2,29 @@ * @file Integration tests for Cloud Manager email bounce banners. */ -import { Notification } from '@linode/api-v4'; import { notificationFactory } from '@src/factories/notification'; -import { mockGetNotifications } from 'support/intercepts/events'; import { getProfile } from 'support/api/account'; -import { ui } from 'support/ui'; +import { mockGetAccount, mockUpdateAccount } from 'support/intercepts/account'; +import { mockGetNotifications } from 'support/intercepts/events'; import { mockUpdateProfile } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; import { randomString } from 'support/util/random'; + import { accountFactory } from 'src/factories/account'; -import { mockGetAccount, mockUpdateAccount } from 'support/intercepts/account'; + +import type { Notification } from '@linode/api-v4'; const notifications_billing_email_bounce: Notification[] = [ notificationFactory.build({ - type: 'billing_email_bounce', severity: 'major', + type: 'billing_email_bounce', }), ]; const notifications_user_email_bounce: Notification[] = [ notificationFactory.build({ - type: 'user_email_bounce', severity: 'major', + type: 'user_email_bounce', }), ]; @@ -104,8 +106,8 @@ describe('Email bounce banners', () => { cy.get('[id="email"]') .should('be.visible') .should('have.value', userprofileEmail) - .clear() - .type(newEmail); + .clear(); + cy.focused().type(newEmail); cy.get('[data-qa-textfield-label="Email"]') .parent() diff --git a/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts b/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts index cef3136b233..f18d980d3ea 100644 --- a/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/account/oauth-apps.spec.ts @@ -7,7 +7,8 @@ import { mockUpdateOAuthApps, } from 'support/intercepts/profile'; import { ui } from 'support/ui'; -import { randomLabel, randomHex } from 'support/util/random'; +import { randomHex, randomLabel } from 'support/util/random'; + import type { OAuthClient } from '@linode/api-v4'; /** @@ -30,11 +31,12 @@ const createOAuthApp = (oauthApp: OAuthClient) => { .findByTitle('Create OAuth App') .should('be.visible') .within(() => { - cy.findByLabelText('Label').click().clear().type(oauthApp.label); - cy.findByLabelText('Callback URL') - .click() - .clear() - .type(oauthApp.redirect_uri); + cy.findByLabelText('Label').click(); + cy.focused().clear(); + cy.focused().type(oauthApp.label); + cy.findByLabelText('Callback URL').click(); + cy.focused().clear(); + cy.focused().type(oauthApp.redirect_uri); ui.buttonGroup .findButtonByTitle('Cancel') .should('be.visible') @@ -56,11 +58,12 @@ const createOAuthApp = (oauthApp: OAuthClient) => { .findByTitle('Create OAuth App') .should('be.visible') .within(() => { - cy.findByLabelText('Label').click().clear().type(oauthApp.label); - cy.findByLabelText('Callback URL') - .click() - .clear() - .type(oauthApp.redirect_uri); + cy.findByLabelText('Label').click(); + cy.focused().clear(); + cy.focused().type(oauthApp.label); + cy.findByLabelText('Callback URL').click(); + cy.focused().clear(); + cy.focused().type(oauthApp.redirect_uri); }); ui.drawerCloseButton.find().click(); @@ -80,8 +83,10 @@ const createOAuthApp = (oauthApp: OAuthClient) => { .should('be.visible') .within(() => { // An error message appears when attempting to create an OAuth App without a label - cy.findByLabelText('Label').click().clear(); - cy.findByLabelText('Callback URL').click().clear(); + cy.findByLabelText('Label').click(); + cy.focused().clear(); + cy.findByLabelText('Callback URL').click(); + cy.focused().clear(); ui.button .findByTitle('Create') .should('be.visible') @@ -91,13 +96,14 @@ const createOAuthApp = (oauthApp: OAuthClient) => { cy.findByText('Redirect URI is required.'); // Fill out and submit OAuth App create form. - cy.findByLabelText('Label').click().clear().type(oauthApp.label); - cy.findByLabelText('Callback URL') - .click() - .clear() - .type(oauthApp.redirect_uri); - // Check the 'public' checkbox - if (oauthApp.public) { + cy.findByLabelText('Label').click(); + cy.focused().clear(); + cy.focused().type(oauthApp.label); + cy.findByLabelText('Callback URL').click(); + cy.focused().clear(); + cy.focused().type(oauthApp.redirect_uri); + // Uncheck the 'public' checkbox + if (!oauthApp.public) { cy.get('[data-qa-checked]').should('be.visible').click(); } mockCreateOAuthApp(oauthApp).as('createOauthApp'); @@ -144,8 +150,8 @@ describe('OAuth Apps', () => { }), oauthClientFactory.build({ label: randomLabel(), - secret: randomHex(64), public: true, + secret: randomHex(64), }), ]; @@ -320,11 +326,12 @@ describe('OAuth Apps', () => { .should('be.visible') .should('be.disabled'); - cy.findByLabelText('Label').click().clear().type(updatedApps[0].label); - cy.findByLabelText('Callback URL') - .click() - .clear() - .type(updatedApps[0].label); + cy.findByLabelText('Label').click(); + cy.focused().clear(); + cy.focused().type(updatedApps[0].label); + cy.findByLabelText('Callback URL').click(); + cy.focused().clear(); + cy.focused().type(updatedApps[0].label); ui.buttonGroup .findButtonByTitle('Save Changes') diff --git a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts index 7df2637bbc7..81d1f357097 100644 --- a/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts +++ b/packages/manager/cypress/e2e/core/account/personal-access-tokens.spec.ts @@ -2,9 +2,6 @@ * @file Integration tests for personal access token CRUD operations. */ -import { Token } from '@linode/api-v4'; -import { appTokenFactory } from 'src/factories/oauth'; -import { profileFactory } from 'src/factories/profile'; import { mockCreatePersonalAccessToken, mockGetAppTokens, @@ -13,10 +10,15 @@ import { mockRevokePersonalAccessToken, mockUpdatePersonalAccessToken, } from 'support/intercepts/profile'; -import { randomLabel, randomString } from 'support/util/random'; import { ui } from 'support/ui'; +import { randomLabel, randomString } from 'support/util/random'; + +import { appTokenFactory } from 'src/factories/oauth'; +import { profileFactory } from 'src/factories/profile'; import { PROXY_USER_RESTRICTED_TOOLTIP_TEXT } from 'src/features/Account/constants'; +import type { Token } from '@linode/api-v4'; + describe('Personal access tokens', () => { /* * - Uses mocked API requests to confirm UI flow to create a personal access token @@ -69,11 +71,8 @@ describe('Personal access tokens', () => { cy.findAllByText('Child Account Access').should('not.exist'); // Confirm submit button is disabled without specifying scopes. - ui.buttonGroup - .findButtonByTitle('Create Token') - .scrollIntoView() - .should('be.visible') - .should('be.disabled'); + ui.buttonGroup.findButtonByTitle('Create Token').scrollIntoView(); + ui.buttonGroup.findButtonByTitle('Create Token').should('be.disabled'); // Select just one scope. cy.get('[data-qa-row="Account"]').within(() => { @@ -81,9 +80,9 @@ describe('Personal access tokens', () => { }); // Confirm submit button is still disabled without specifying ALL scopes. + ui.buttonGroup.findButtonByTitle('Create Token').scrollIntoView(); ui.buttonGroup .findButtonByTitle('Create Token') - .scrollIntoView() .should('be.visible') .should('be.disabled'); @@ -96,29 +95,32 @@ describe('Personal access tokens', () => { ); // Confirm submit button is enabled; attempt to submit form without specifying a label. + ui.buttonGroup.findButtonByTitle('Create Token').scrollIntoView(); ui.buttonGroup .findButtonByTitle('Create Token') - .scrollIntoView() .should('be.visible') .should('be.enabled') .click(); // Confirm validation error. - cy.findByText('Label must be between 1 and 100 characters.') - .scrollIntoView() - .should('be.visible'); + cy.findByText( + 'Label must be between 1 and 100 characters.' + ).scrollIntoView(); + cy.findByText('Label must be between 1 and 100 characters.').should( + 'be.visible' + ); // Specify a label and re-submit. + cy.findByLabelText('Label').scrollIntoView(); cy.findByLabelText('Label') - .scrollIntoView() .should('be.visible') .should('be.enabled') - .click() - .type(token.label); + .click(); + cy.findByLabelText('Label').type(token.label); + ui.buttonGroup.findButtonByTitle('Create Token').scrollIntoView(); ui.buttonGroup .findButtonByTitle('Create Token') - .scrollIntoView() .should('be.visible') .should('be.enabled') .click(); @@ -219,11 +221,9 @@ describe('Personal access tokens', () => { .findByTitle('Edit Personal Access Token') .should('be.visible') .within(() => { - cy.findByLabelText('Label') - .should('be.visible') - .click() - .clear() - .type(newToken.label); + cy.findByLabelText('Label').as('qaLabel').should('be.visible').click(); + cy.get('@qaLabel').clear(); + cy.get('@qaLabel').type(newToken.label); ui.buttonGroup .findButtonByTitle('Save') diff --git a/packages/manager/cypress/e2e/core/account/security-questions.spec.ts b/packages/manager/cypress/e2e/core/account/security-questions.spec.ts index bc280f36e5a..3d161c672b4 100644 --- a/packages/manager/cypress/e2e/core/account/security-questions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/security-questions.spec.ts @@ -2,8 +2,6 @@ * @file Integration tests for account security questions. */ -import { profileFactory } from 'src/factories/profile'; -import { securityQuestionsFactory } from 'src/factories/profile'; import { mockGetProfile, mockGetSecurityQuestions, @@ -11,6 +9,9 @@ import { } from 'support/intercepts/profile'; import { ui } from 'support/ui'; +import { securityQuestionsFactory } from 'src/factories/profile'; +import { profileFactory } from 'src/factories/profile'; + /** * Finds the "Security Questions" section on the profile auth page. * @@ -95,16 +96,16 @@ const setSecurityQuestionAnswer = ( getSecurityQuestion(questionNumber).within(() => { cy.findByLabelText(`Question ${questionNumber}`) .should('be.visible') - .click() - .type(`${question}{enter}`); + .click(); + cy.focused().type(`${question}{enter}`); }); getSecurityQuestionAnswer(questionNumber).within(() => { cy.findByLabelText(`Answer ${questionNumber}`) .should('be.visible') .should('be.enabled') - .click() - .type(answer); + .click(); + cy.focused().type(answer); }); }; diff --git a/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts b/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts index 460c2ff49ba..ddb33dc2639 100644 --- a/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts +++ b/packages/manager/cypress/e2e/core/account/service-transfer.spec.ts @@ -3,33 +3,38 @@ */ import { getProfile } from '@linode/api-v4/lib/profile'; -import { EntityTransfer, Linode, Profile } from '@linode/api-v4'; -import { entityTransferFactory } from 'src/factories/entityTransfers'; -import { linodeFactory } from 'src/factories'; -import { createLinodeRequestFactory } from 'src/factories/linodes'; -import { formatDate } from 'src/utilities/formatDate'; import { authenticate } from 'support/api/authentication'; +import { visitUrlWithManagedEnabled } from 'support/api/managed'; import { interceptInitiateEntityTransfer, mockAcceptEntityTransfer, mockGetEntityTransfers, - mockReceiveEntityTransfer, + mockGetEntityTransfersError, mockInitiateEntityTransferError, + mockReceiveEntityTransfer, } from 'support/intercepts/account'; import { mockGetLinodes } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; import { pollLinodeStatus } from 'support/util/polling'; import { randomLabel, randomUuid } from 'support/util/random'; -import { visitUrlWithManagedEnabled } from 'support/api/managed'; import { chooseRegion } from 'support/util/regions'; -import { cleanUp } from 'support/util/cleanup'; + +import { linodeFactory } from 'src/factories'; +import { entityTransferFactory } from 'src/factories/entityTransfers'; +import { createLinodeRequestFactory } from 'src/factories/linodes'; +import { formatDate } from 'src/utilities/formatDate'; import type { EntityTransferStatus } from '@linode/api-v4'; +import type { EntityTransfer, Linode, Profile } from '@linode/api-v4'; // Service transfer empty state message. const serviceTransferEmptyState = 'No data to display.'; +// Service transfer error message. +export const serviceTransferErrorMessage = 'An unknown error has occurred'; + // Service transfer landing page URL. const serviceTransferLandingUrl = '/account/service-transfers'; @@ -79,10 +84,8 @@ const initiateLinodeTransfer = (linodeLabel: string) => { * @param token - Token to attempt to redeem. */ const redeemToken = (token: string) => { - cy.findByLabelText('Receive a Service Transfer') - .should('be.visible') - .click() - .type(token); + cy.findByLabelText('Receive a Service Transfer').should('be.visible').click(); + cy.focused().type(token); ui.button .findByTitle('Review Details') @@ -163,20 +166,20 @@ describe('Account service transfers', () => { cy.get('[data-qa-panel="Pending Service Transfers"]').should('not.exist'); // Confirm that text "No data to display" is in "Received Service Transfers" panel. - cy.get('[data-qa-panel="Received Service Transfers"]') - .should('be.visible') - .within(() => { - cy.get('[role="button"]').click(); - cy.findByText(serviceTransferEmptyState, { exact: false }).should( - 'be.visible' - ); - }); + cy.findByText('Received Service Transfers').should('be.visible').click(); + + cy.get('[data-qa-panel="Received Service Transfers"]').within(() => { + cy.findByText(serviceTransferEmptyState, { exact: false }).should( + 'be.visible' + ); + }); // Confirm that text "No data to display" is in "Sent Service Transfers" panel. + cy.findByText('Sent Service Transfers').should('be.visible').click(); + cy.get('[data-qa-panel="Sent Service Transfers"]') .should('be.visible') .within(() => { - cy.get('[role="button"]').click(); cy.findByText(serviceTransferEmptyState, { exact: false }).should( 'be.visible' ); @@ -188,25 +191,25 @@ describe('Account service transfers', () => { */ it('lists service transfers on landing page', () => { const pendingTransfers = entityTransferFactory.buildList(3, { - status: 'pending', entities: { linodes: [0, 1, 2, 3, 4], }, + status: 'pending', }); const receivedTransfers = entityTransferFactory.buildList(4, { - is_sender: false, entities: { linodes: [0], }, + is_sender: false, }); const sentTransfers = serviceTransferStatuses.map((status) => { return entityTransferFactory.build({ - is_sender: true, entities: { linodes: [0, 1], }, + is_sender: true, status, }); }); @@ -432,12 +435,12 @@ describe('Account service transfers', () => { it('can receive a service transfer', () => { const token = randomUuid(); const transfer = entityTransferFactory.build({ - token, entities: { linodes: [0], }, - status: 'pending', is_sender: false, + status: 'pending', + token, }); mockGetEntityTransfers([], [], []).as('getTransfers'); @@ -483,10 +486,8 @@ describe('Account service transfers', () => { ui.toast.assertMessage('Transfer accepted successfully.'); cy.get('[data-qa-panel="Received Service Transfers"]') .should('be.visible') - .click() - .within(() => { - cy.findByText(token).should('be.visible'); - }); + .click(); + cy.findByText(token).should('be.visible'); }); /* @@ -522,4 +523,33 @@ describe('Account service transfers', () => { initiateLinodeTransfer(mockLinodes[0].label); cy.findByText(errorMessage).should('be.visible'); }); + + /* + * - Confirms that an error message is displayed in both the Received and Sent tables when the requests to fetch service transfers fail. + */ + it('should display an error message when the request fails to fetch service transfer', () => { + mockGetEntityTransfersError().as('getTransfersError'); + + cy.visitWithLogin(serviceTransferLandingUrl); + cy.wait('@getTransfersError'); + + cy.get('[data-qa-panel="Pending Service Transfers"]').should('not.exist'); + + // Confirm that an error message is displayed in both "Received Service Transfers" and "Sent Service Transfers" panels. + ['Received Service Transfers', 'Sent Service Transfers'].forEach( + (transfer) => { + cy.get(`[data-qa-panel="${transfer}"]`) + .should('be.visible') + .within(() => { + cy.get(`[data-qa-panel-summary="${transfer}"]`).click(); + // Error Icon should shows up. + cy.findByTestId('ErrorOutlineIcon').should('be.visible'); + // Error message should be visible. + cy.findByText(serviceTransferErrorMessage, { exact: false }).should( + 'be.visible' + ); + }); + } + ); + }); }); diff --git a/packages/manager/cypress/e2e/core/account/smoke-enroll-beta.spec.ts b/packages/manager/cypress/e2e/core/account/smoke-enroll-beta.spec.ts index b341be1c977..17bfa535985 100644 --- a/packages/manager/cypress/e2e/core/account/smoke-enroll-beta.spec.ts +++ b/packages/manager/cypress/e2e/core/account/smoke-enroll-beta.spec.ts @@ -1,13 +1,13 @@ import { accountBetaFactory, betaFactory } from '@src/factories'; -import { authenticate } from 'support/api/authentication'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { DateTime } from 'luxon'; +import { authenticate } from 'support/api/authentication'; import { mockGetAccountBetas, - mockGetBetas, mockGetBeta, + mockGetBetas, mockPostBeta, } from 'support/intercepts/betas'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; authenticate(); @@ -21,17 +21,17 @@ describe('Enroll in a Beta Program', () => { selfServeBetas: true, }).as('getFeatureFlags'); const currentlyEnrolledBeta = accountBetaFactory.build({ - id: '12345', enrolled: DateTime.now().minus({ days: 10 }).toISO(), + id: '12345', started: DateTime.now().minus({ days: 11 }).toISO(), }); const availableBetas = betaFactory.buildList(2); const historicalBetas = accountBetaFactory.buildList(2, { + ended: DateTime.now().minus({ days: 5 }).toISO(), + enrolled: DateTime.now().minus({ days: 10 }).toISO(), id: '1234', label: 'Historical Beta', started: DateTime.now().minus({ days: 15 }).toISO(), - enrolled: DateTime.now().minus({ days: 10 }).toISO(), - ended: DateTime.now().minus({ days: 5 }).toISO(), }); const accountBetas = [currentlyEnrolledBeta, ...historicalBetas]; diff --git a/packages/manager/cypress/e2e/core/account/sms-verification.spec.ts b/packages/manager/cypress/e2e/core/account/sms-verification.spec.ts index 68227b396b9..6a5ed1aed43 100644 --- a/packages/manager/cypress/e2e/core/account/sms-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/account/sms-verification.spec.ts @@ -2,13 +2,6 @@ * @file Integration tests for SMS phone verification. */ -import { getFormattedNumber } from 'src/features/Profile/AuthenticationSettings/PhoneVerification/helpers'; -import { profileFactory } from 'src/factories/profile'; -import { - randomLabel, - randomNumber, - randomPhoneNumber, -} from 'support/util/random'; import { mockGetProfile, mockSendVerificationCode, @@ -16,6 +9,14 @@ import { mockVerifyVerificationCode, } from 'support/intercepts/profile'; import { ui } from 'support/ui'; +import { + randomLabel, + randomNumber, + randomPhoneNumber, +} from 'support/util/random'; + +import { profileFactory } from 'src/factories/profile'; +import { getFormattedNumber } from 'src/features/Profile/AuthenticationSettings/PhoneVerification/helpers'; describe('SMS phone verification', () => { /* @@ -53,7 +54,8 @@ describe('SMS phone verification', () => { // @TODO Add steps to change country code before typing phone number. - cy.findByLabelText('Phone Number').click().type(optInPhoneNumber); + cy.findByLabelText('Phone Number').click(); + cy.focused().type(optInPhoneNumber); ui.button .findByTitle('Send Verification Code') @@ -65,10 +67,8 @@ describe('SMS phone verification', () => { cy.findByText(confirmationMessage, { exact: false }).should('be.visible'); // Mock invalid verification code for first attempt. - cy.findByLabelText('Verification Code') - .should('be.visible') - .click() - .type(`${randomNumber(10000, 50000)}`); + cy.findByLabelText('Verification Code').should('be.visible').click(); + cy.focused().type(`${randomNumber(10000, 50000)}`); ui.button .findByTitle('Verify Phone Number') @@ -87,11 +87,9 @@ describe('SMS phone verification', () => { // Mock successful verification code for second attempt. mockVerifyVerificationCode().as('verifyCode'); - cy.findByLabelText('Verification Code') - .should('be.visible') - .click() - .clear() - .type(`${randomNumber(10000, 50000)}`); + cy.findByLabelText('Verification Code').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(`${randomNumber(10000, 50000)}`); ui.button .findByTitle('Verify Phone Number') diff --git a/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts b/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts index d0cf29ac00d..5afeac8936c 100644 --- a/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts +++ b/packages/manager/cypress/e2e/core/account/ssh-keys.spec.ts @@ -1,4 +1,4 @@ -import { sshKeyFactory } from 'src/factories'; +import { sshFormatErrorMessage } from 'support/constants/account'; import { mockCreateSSHKey, mockCreateSSHKeyError, @@ -8,7 +8,8 @@ import { } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { randomLabel, randomString } from 'support/util/random'; -import { sshFormatErrorMessage } from 'support/constants/account'; + +import { sshKeyFactory } from 'src/factories'; describe('SSH keys', () => { /* @@ -21,11 +22,11 @@ describe('SSH keys', () => { */ it('adds an SSH key via Profile page as expected', () => { const randomKey = randomString(400, { - uppercase: true, lowercase: true, numbers: true, spaces: false, symbols: false, + uppercase: true, }); const mockSSHKey = sshKeyFactory.build({ label: randomLabel(), @@ -57,7 +58,8 @@ describe('SSH keys', () => { cy.findByText('Label is required.'); // When a user tries to create an SSH key without the SSH Public Key, a form validation error appears - cy.get('[id="label"]').clear().type(mockSSHKey.label); + cy.get('[id="label"]').clear(); + cy.focused().type(mockSSHKey.label); ui.button .findByTitle('Add Key') .should('be.visible') @@ -66,7 +68,8 @@ describe('SSH keys', () => { cy.findAllByText(sshFormatErrorMessage).should('be.visible'); // An alert displays when the format of SSH key is incorrect - cy.get('[id="ssh-public-key"]').clear().type('WrongFormatSshKey'); + cy.get('[id="ssh-public-key"]').clear(); + cy.focused().type('WrongFormatSshKey'); ui.button .findByTitle('Add Key') .should('be.visible') @@ -74,7 +77,8 @@ describe('SSH keys', () => { .click(); cy.findAllByText(sshFormatErrorMessage).should('be.visible'); - cy.get('[id="ssh-public-key"]').clear().type(mockSSHKey.ssh_key); + cy.get('[id="ssh-public-key"]').clear(); + cy.focused().type(mockSSHKey.ssh_key); ui.button .findByTitle('Cancel') .should('be.visible') @@ -101,8 +105,10 @@ describe('SSH keys', () => { cy.get('[id="ssh-public-key"]').should('be.empty'); // Create a new ssh key - cy.get('[id="label"]').clear().type(mockSSHKey.label); - cy.get('[id="ssh-public-key"]').clear().type(mockSSHKey.ssh_key); + cy.get('[id="label"]').clear(); + cy.focused().type(mockSSHKey.label); + cy.get('[id="ssh-public-key"]').clear(); + cy.focused().type(mockSSHKey.ssh_key); ui.button .findByTitle('Add Key') .should('be.visible') @@ -127,11 +133,11 @@ describe('SSH keys', () => { const errorMessage = 'failed to add an SSH key.'; const sshKeyLabel = randomLabel(); const randomKey = randomString(400, { - uppercase: true, lowercase: true, numbers: true, spaces: false, symbols: false, + uppercase: true, }); const sshPublicKey = `ssh-rsa e2etestkey${randomKey} e2etest@linode`; @@ -157,8 +163,10 @@ describe('SSH keys', () => { cy.get('[id="ssh-public-key"]').should('be.empty'); // Create a new ssh key - cy.get('[id="label"]').clear().type(sshKeyLabel); - cy.get('[id="ssh-public-key"]').clear().type(sshPublicKey); + cy.get('[id="label"]').clear(); + cy.focused().type(sshKeyLabel); + cy.get('[id="ssh-public-key"]').clear(); + cy.focused().type(sshPublicKey); ui.button .findByTitle('Add Key') .should('be.visible') @@ -180,11 +188,11 @@ describe('SSH keys', () => { */ it('updates an SSH key via Profile page as expected', () => { const randomKey = randomString(400, { - uppercase: true, lowercase: true, numbers: true, spaces: false, symbols: false, + uppercase: true, }); const mockSSHKey = sshKeyFactory.build({ label: randomLabel(), @@ -228,7 +236,8 @@ describe('SSH keys', () => { cy.findByText('Label is required.'); // SSH label is not modified when the operation is cancelled - cy.get('[id="label"]').clear().type(newSSHKeyLabel); + cy.get('[id="label"]').clear(); + cy.focused().type(newSSHKeyLabel); ui.button .findByTitle('Cancel') .should('be.visible') @@ -250,7 +259,8 @@ describe('SSH keys', () => { .should('be.visible') .within(() => { // Update a new ssh key - cy.get('[id="label"]').clear().type(newSSHKeyLabel); + cy.get('[id="label"]').clear(); + cy.focused().type(newSSHKeyLabel); ui.button .findByTitle('Save') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts b/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts index 0f020d73aa0..3f6a4761a6f 100644 --- a/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts +++ b/packages/manager/cypress/e2e/core/account/third-party-access-tokens.spec.ts @@ -1,16 +1,18 @@ +import { getProfile } from '@linode/api-v4/lib/profile'; import { accessFactory, appTokenFactory } from '@src/factories'; import 'cypress-file-upload'; +import { authenticate } from 'support/api/authentication'; import { - mockGetPersonalAccessTokens, mockGetAppTokens, + mockGetPersonalAccessTokens, mockRevokeAppToken, } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { randomLabel, randomString } from 'support/util/random'; -import { Token, Profile } from '@linode/api-v4'; -import { getProfile } from '@linode/api-v4/lib/profile'; + import { formatDate } from 'src/utilities/formatDate'; -import { authenticate } from 'support/api/authentication'; + +import type { Profile, Token } from '@linode/api-v4'; authenticate(); describe('Third party access tokens', () => { diff --git a/packages/manager/cypress/e2e/core/account/two-factor-auth.spec.ts b/packages/manager/cypress/e2e/core/account/two-factor-auth.spec.ts index f9fd7080754..9f1e945cb5a 100644 --- a/packages/manager/cypress/e2e/core/account/two-factor-auth.spec.ts +++ b/packages/manager/cypress/e2e/core/account/two-factor-auth.spec.ts @@ -2,11 +2,6 @@ * @file Integration tests for account two-factor authentication functionality. */ -import { SecurityQuestionsData } from '@linode/api-v4'; -import { - profileFactory, - securityQuestionsFactory, -} from 'src/factories/profile'; import { mockConfirmTwoFactorAuth, mockDisableTwoFactorAuth, @@ -16,12 +11,19 @@ import { } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { - randomNumber, + randomHex, randomLabel, + randomNumber, randomString, - randomHex, } from 'support/util/random'; +import { + profileFactory, + securityQuestionsFactory, +} from 'src/factories/profile'; + +import type { SecurityQuestionsData } from '@linode/api-v4'; + /** * Returns a Cypress chainable for the "Two-Factor Authentication". * @@ -39,10 +41,10 @@ const getTwoFactorSection = (): Cypress.Chainable => { const randomScratchCode = (): string => { const randomScratchCodeOptions = { lowercase: true, - uppercase: false, - symbols: false, numbers: false, spaces: false, + symbols: false, + uppercase: false, }; const segmentA = randomString(5, randomScratchCodeOptions); @@ -61,10 +63,10 @@ const randomScratchCode = (): string => { const randomToken = (): string => { const randomTokenOptions = { lowercase: false, - uppercase: false, numbers: true, - symbols: false, spaces: false, + symbols: false, + uppercase: false, }; return randomString(6, randomTokenOptions); @@ -103,10 +105,10 @@ const getAnsweredSecurityQuestions = (): SecurityQuestionsData => { // User profile with 2FA disabled. const userProfile = profileFactory.build({ + two_factor_auth: false, uid: randomNumber(1000, 9999), username: randomLabel(), verified_phone_number: undefined, - two_factor_auth: false, }); // User profile with 2FA enabled. diff --git a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts index 7d97c8251f5..ccaa315dfa9 100644 --- a/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-permissions.spec.ts @@ -1,4 +1,3 @@ -import type { Grant, Grants } from '@linode/api-v4'; import { profileFactory } from '@src/factories'; import { accountUserFactory } from '@src/factories/accountUsers'; import { grantsFactory } from '@src/factories/grants'; @@ -16,6 +15,8 @@ import { ui } from 'support/ui'; import { shuffleArray } from 'support/util/arrays'; import { randomLabel } from 'support/util/random'; +import type { Grant, Grants } from '@linode/api-v4'; + // Message shown when user has unrestricted account access. const unrestrictedAccessMessage = 'This user has unrestricted access to the account.'; @@ -175,8 +176,8 @@ describe('User permission management', () => { */ it('can toggle full account access', () => { const mockUser = accountUserFactory.build({ - username: randomLabel(), restricted: false, + username: randomLabel(), }); const mockUserUpdated = { @@ -266,8 +267,8 @@ describe('User permission management', () => { */ it('can update global and specific permissions', () => { const mockUser = accountUserFactory.build({ - username: randomLabel(), restricted: true, + username: randomLabel(), }); const mockUserGrants = { ...userPermissionsGrants }; @@ -278,18 +279,20 @@ describe('User permission management', () => { ...mockUserGrants, global: { account_access: 'read_only', - cancel_account: true, - child_account_access: true, - add_domains: true, + add_buckets: true, add_databases: true, + add_domains: true, add_firewalls: true, add_images: true, + add_kubernetes: true, add_linodes: true, add_longview: true, add_nodebalancers: true, add_stackscripts: true, add_volumes: true, add_vpcs: true, + cancel_account: true, + child_account_access: true, longview_subscription: true, }, }; @@ -385,8 +388,8 @@ describe('User permission management', () => { */ it('can reset user permissions changes', () => { const mockUser = accountUserFactory.build({ - username: randomLabel(), restricted: true, + username: randomLabel(), }); const mockUserGrants = { ...userPermissionsGrants }; @@ -485,9 +488,9 @@ describe('User permission management', () => { }); const mockActiveUser = accountUserFactory.build({ - username: 'unrestricted-child-user', restricted: false, user_type: 'child', + username: 'unrestricted-child-user', }); const mockRestrictedUser = { @@ -543,8 +546,8 @@ describe('User permission management', () => { */ it('tests the user permissions for a child account viewing a proxy user', () => { const mockChildProfile = profileFactory.build({ - username: 'proxy-user', user_type: 'child', + username: 'proxy-user', }); const mockChildUser = accountUserFactory.build({ diff --git a/packages/manager/cypress/e2e/core/account/user-profile.spec.ts b/packages/manager/cypress/e2e/core/account/user-profile.spec.ts index fc41156e199..b288cdd14ea 100644 --- a/packages/manager/cypress/e2e/core/account/user-profile.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-profile.spec.ts @@ -1,4 +1,3 @@ -import { accountUserFactory } from 'src/factories/accountUsers'; import { getProfile } from 'support/api/account'; import { interceptGetUser, @@ -6,9 +5,11 @@ import { mockGetUsers, mockUpdateUsername, } from 'support/intercepts/account'; -import { randomString } from 'support/util/random'; -import { ui } from 'support/ui'; import { mockUpdateProfile } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; +import { randomString } from 'support/util/random'; + +import { accountUserFactory } from 'src/factories/accountUsers'; import { PARENT_USER, RESTRICTED_FIELD_TOOLTIP, @@ -55,8 +56,8 @@ describe('User Profile', () => { cy.get('[id="email"]') .should('be.visible') .should('have.value', activeEmail) - .clear() - .type(newEmail); + .clear(); + cy.focused().type(newEmail); cy.get('[data-qa-textfield-label="Email"]') .parent() @@ -79,8 +80,8 @@ describe('User Profile', () => { cy.get('[id="username"]') .should('be.visible') .should('have.value', activeUsername) - .clear() - .type(newUsername); + .clear(); + cy.focused().type(newUsername); cy.get('[data-qa-textfield-label="Username"]') .parent() @@ -167,8 +168,8 @@ describe('User Profile', () => { cy.get('[id="username"]') .should('be.visible') .should('have.value', additionalUsername) - .clear() - .type(newUsername); + .clear(); + cy.focused().type(newUsername); cy.get('[data-qa-textfield-label="Username"]') .parent() @@ -199,8 +200,8 @@ describe('User Profile', () => { getProfile().then((profile) => { const proxyUsername = 'proxy_user'; const mockAccountUsers = accountUserFactory.buildList(1, { - username: proxyUsername, user_type: 'proxy', + username: proxyUsername, }); mockGetUsers(mockAccountUsers).as('getUsers'); diff --git a/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts b/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts index 2b2fd767d4f..d2547970203 100644 --- a/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts +++ b/packages/manager/cypress/e2e/core/account/user-verification-banner.spec.ts @@ -1,15 +1,15 @@ import { profileFactory, securityQuestionsFactory } from '@src/factories'; import { accountUserFactory } from '@src/factories/accountUsers'; import { grantsFactory } from '@src/factories/grants'; +import { verificationBannerNotice } from 'support/constants/user'; import { mockGetUser, mockGetUserGrants, mockGetUsers, } from 'support/intercepts/account'; import { mockGetSecurityQuestions } from 'support/intercepts/profile'; -import { ui } from 'support/ui'; import { mockGetProfile } from 'support/intercepts/profile'; -import { verificationBannerNotice } from 'support/constants/user'; +import { ui } from 'support/ui'; describe('User verification banner', () => { /* @@ -18,15 +18,15 @@ describe('User verification banner', () => { */ it('can show up when a child user has not associated a phone number or set up security questions for their account', () => { const mockChildProfile = profileFactory.build({ - username: 'child-user', user_type: 'child', + username: 'child-user', verified_phone_number: null, }); const mockChildUser = accountUserFactory.build({ restricted: false, - username: 'child-user', user_type: 'child', + username: 'child-user', verified_phone_number: null, }); @@ -85,15 +85,15 @@ describe('User verification banner', () => { */ it('can show up when a child user has set up security questions but not a phone number for their account', () => { const mockChildProfile = profileFactory.build({ - username: 'child-user', user_type: 'child', + username: 'child-user', verified_phone_number: null, }); const mockChildUser = accountUserFactory.build({ restricted: false, - username: 'child-user', user_type: 'child', + username: 'child-user', verified_phone_number: null, }); @@ -162,15 +162,15 @@ describe('User verification banner', () => { */ it('does not show up when a child user adds a phone number and sets up security questions', () => { const mockChildProfile = profileFactory.build({ - username: 'child-user', user_type: 'child', + username: 'child-user', verified_phone_number: '+15555555555', }); const mockChildUser = accountUserFactory.build({ restricted: false, - username: 'child-user', user_type: 'child', + username: 'child-user', verified_phone_number: '+15555555555', }); diff --git a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts index eb9db1dcfaa..af4179b74c5 100644 --- a/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/account/users-landing-page.spec.ts @@ -1,14 +1,13 @@ import { profileFactory } from '@src/factories'; import { accountUserFactory } from '@src/factories/accountUsers'; import { grantsFactory } from '@src/factories/grants'; -import type { Profile } from '@linode/api-v4'; import { mockAddUser, + mockDeleteUser, mockGetUser, mockGetUserGrants, mockGetUserGrantsUnrestrictedAccess, mockGetUsers, - mockDeleteUser, } from 'support/intercepts/account'; import { mockGetProfile, @@ -16,8 +15,11 @@ import { } from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; + import { PARENT_USER } from 'src/features/Account/constants'; +import type { Profile } from '@linode/api-v4'; + /** * Initialize test users before tests * @@ -31,16 +33,16 @@ const initTestUsers = (profile: Profile, enableChildAccountAccess: boolean) => { const mockRestrictedParentWithoutChildAccountAccess = accountUserFactory.build( { - username: 'restricted-parent-user-without-child-account-access', restricted: true, user_type: 'parent', + username: 'restricted-parent-user-without-child-account-access', } ); const mockRestrictedParentWithChildAccountAccess = accountUserFactory.build({ - username: 'restricted-parent-user-with-child-account-access', restricted: true, user_type: 'parent', + username: 'restricted-parent-user-with-child-account-access', }); const mockUsers = [ @@ -89,9 +91,9 @@ describe('Users landing page', () => { */ it('shows "Child account access" column for unrestricted parent users and shows restricted parent users who have the correct grant status', () => { const mockProfile = profileFactory.build({ - username: 'unrestricted-parent-user', restricted: false, user_type: 'parent', + username: 'unrestricted-parent-user', }); const mockUsers = initTestUsers(mockProfile, true); @@ -122,9 +124,9 @@ describe('Users landing page', () => { it('shows "Child account access" column for restricted parent users with child_account_access grant set to true', () => { const mockProfile = profileFactory.build({ - username: 'restricted-parent-user', restricted: true, user_type: 'parent', + username: 'restricted-parent-user', }); initTestUsers(mockProfile, true); @@ -138,9 +140,9 @@ describe('Users landing page', () => { it('hides "Child account access" column for restricted parent users with child_account_access grant set to false', () => { const mockProfile = profileFactory.build({ - username: 'restricted-parent-user', restricted: true, user_type: 'parent', + username: 'restricted-parent-user', }); initTestUsers(mockProfile, false); @@ -154,8 +156,8 @@ describe('Users landing page', () => { it('hides "Child account access" column for default users', () => { const mockProfile = profileFactory.build({ - username: 'default-user', restricted: false, + username: 'default-user', }); initTestUsers(mockProfile, false); @@ -170,9 +172,9 @@ describe('Users landing page', () => { it('hides "Child account access" column for proxy users', () => { const mockProfile = profileFactory.build({ - username: 'proxy-user', restricted: false, user_type: 'proxy', + username: 'proxy-user', }); initTestUsers(mockProfile, false); @@ -187,9 +189,9 @@ describe('Users landing page', () => { it('hides "Child account access" column for child users', () => { const mockProfile = profileFactory.build({ - username: 'child-user', restricted: false, user_type: 'child', + username: 'child-user', }); initTestUsers(mockProfile, false); @@ -207,14 +209,14 @@ describe('Users landing page', () => { */ it('hides "Parent User Settings" section for parent users', () => { const mockProfile = profileFactory.build({ - username: 'unrestricted-parent-user', restricted: false, user_type: 'parent', + username: 'unrestricted-parent-user', }); const mockUser = accountUserFactory.build({ - username: 'unrestricted-user', restricted: false, + username: 'unrestricted-user', }); // Initially mock user with unrestricted account access. @@ -240,8 +242,8 @@ describe('Users landing page', () => { */ it('tests the users landing flow for a child account viewing a proxy user', () => { const mockChildProfile = profileFactory.build({ - username: 'child-user', user_type: 'child', + username: 'child-user', }); const mockChildUser = accountUserFactory.build({ @@ -297,15 +299,15 @@ describe('Users landing page', () => { it('can add users with full access', () => { const mockUser = accountUserFactory.build({ - username: randomLabel(), restricted: false, + username: randomLabel(), }); const username = randomLabel(); const newUser = accountUserFactory.build({ - username: username, email: `${username}@test.com`, restricted: false, + username, }); mockGetUsers([mockUser]).as('getUsers'); @@ -333,10 +335,10 @@ describe('Users landing page', () => { .findByTitle('Add a User') .should('be.visible') .within(() => { - cy.findByText('Username').click().type(`${newUser.username}{enter}`); - cy.findByText('Email') - .click() - .type(`${newUser.username}@test.com{enter}`); + cy.findByText('Username').click(); + cy.focused().type(`${newUser.username}{enter}`); + cy.findByText('Email').click(); + cy.focused().type(`${newUser.username}@test.com{enter}`); ui.buttonGroup .findButtonByTitle('Cancel') .should('be.visible') @@ -358,10 +360,10 @@ describe('Users landing page', () => { .findByTitle('Add a User') .should('be.visible') .within(() => { - cy.findByText('Username').click().type(`${newUser.username}{enter}`); - cy.findByText('Email') - .click() - .type(`${newUser.username}@test.com{enter}`); + cy.findByText('Username').click(); + cy.focused().type(`${newUser.username}{enter}`); + cy.findByText('Email').click(); + cy.focused().type(`${newUser.username}@test.com{enter}`); ui.buttonGroup .findButtonByTitle('Cancel') .should('be.visible') @@ -394,10 +396,12 @@ describe('Users landing page', () => { cy.findByText('Email address is required.').should('be.visible'); // type username - cy.findByText('Username').click().type(`${newUser.username}{enter}`); + cy.findByText('Username').click(); + cy.focused().type(`${newUser.username}{enter}`); // an inline error message will be displayed when the email address is invalid - cy.findByText('Email').click().type(`not_valid_email_address{enter}`); + cy.findByText('Email').click(); + cy.focused().type(`not_valid_email_address{enter}`); ui.buttonGroup .findButtonByTitle('Add User') .should('be.visible') @@ -406,10 +410,9 @@ describe('Users landing page', () => { cy.findByText('Must be a valid Email address.').should('be.visible'); // type email address - cy.get('[id="email"]') - .click() - .clear() - .type(`${newUser.username}@test.com{enter}`); + cy.get('[id="email"]').click(); + cy.focused().clear(); + cy.focused().type(`${newUser.username}@test.com{enter}`); ui.buttonGroup .findButtonByTitle('Add User') @@ -432,15 +435,15 @@ describe('Users landing page', () => { it('can add users with restricted access', () => { const mockUser = accountUserFactory.build({ - username: randomLabel(), restricted: false, + username: randomLabel(), }); const username = randomLabel(); const newUser = accountUserFactory.build({ - username: username, email: `${username}@test.com`, restricted: true, + username, }); mockGetUsers([mockUser]).as('getUsers'); @@ -467,10 +470,10 @@ describe('Users landing page', () => { .findByTitle('Add a User') .should('be.visible') .within(() => { - cy.findByText('Username').click().type(`${newUser.username}{enter}`); - cy.findByText('Email') - .click() - .type(`${newUser.username}@test.com{enter}`); + cy.findByText('Username').click(); + cy.focused().type(`${newUser.username}{enter}`); + cy.findByText('Email').click(); + cy.focused().type(`${newUser.username}@test.com{enter}`); ui.buttonGroup .findButtonByTitle('Cancel') .should('be.visible') @@ -504,10 +507,12 @@ describe('Users landing page', () => { cy.findByText('Email address is required.').should('be.visible'); // type username - cy.findByText('Username').click().type(`${newUser.username}{enter}`); + cy.findByText('Username').click(); + cy.focused().type(`${newUser.username}{enter}`); // an inline error message will be displayed when the email address is invalid - cy.findByText('Email').click().type(`not_valid_email_address{enter}`); + cy.findByText('Email').click(); + cy.focused().type(`not_valid_email_address{enter}`); ui.buttonGroup .findButtonByTitle('Add User') .should('be.visible') @@ -516,10 +521,9 @@ describe('Users landing page', () => { cy.findByText('Must be a valid Email address.').should('be.visible'); // type email address - cy.get('[id="email"]') - .click() - .clear() - .type(`${newUser.username}@test.com{enter}`); + cy.get('[id="email"]').click(); + cy.focused().clear(); + cy.focused().type(`${newUser.username}@test.com{enter}`); // toggle to disable full access cy.get('[data-qa-create-restricted="true"]') @@ -545,15 +549,15 @@ describe('Users landing page', () => { it('can delete users', () => { const mockUser = accountUserFactory.build({ - username: randomLabel(), restricted: false, + username: randomLabel(), }); const username = randomLabel(); const additionalUser = accountUserFactory.build({ - username: username, email: `${username}@test.com`, restricted: false, + username, }); mockGetUsers([mockUser, additionalUser]).as('getUsers'); diff --git a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts index b311d11937b..f2f94a3085d 100644 --- a/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/billing-contact.spec.ts @@ -3,23 +3,22 @@ import { mockUpdateAccount, mockUpdateAccountAgreements, } from 'support/intercepts/account'; -import { accountFactory } from 'src/factories/account'; -import type { Account } from '@linode/api-v4'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetUserPreferences } from 'support/intercepts/profile'; import { ui } from 'support/ui'; + +import { accountAgreementsFactory } from 'src/factories'; +import { accountFactory } from 'src/factories/account'; import { TAX_ID_AGREEMENT_TEXT, TAX_ID_HELPER_TEXT, } from 'src/features/Billing/constants'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { mockGetUserPreferences } from 'support/intercepts/profile'; -import { accountAgreementsFactory } from 'src/factories'; + +import type { Account } from '@linode/api-v4'; /* eslint-disable sonarjs/no-duplicate-string */ const accountData = accountFactory.build({ - company: 'company_name', - email: 'test_email@linode.com', - first_name: 'First name', - last_name: 'Last Name', + active_promotions: [], address_1: 'terrible address address for test', address_2: 'Very long address for test Very long address for test Ve ', balance: 0, @@ -32,25 +31,28 @@ const accountData = accountFactory.build({ 'Kubernetes', ], city: 'philadelphia', + company: 'company_name', country: 'US', - credit_card: { last_four: '4000', expiry: '01/2090' }, + credit_card: { expiry: '01/2090', last_four: '4000' }, + email: 'test_email@linode.com', euuid: '7C1E3EE8-2F65-418A-95EF12E477XXXXXX', + first_name: 'First name', + last_name: 'Last Name', phone: '2154444444', state: 'Pennsylvania', tax_id: '1234567890', zip: '19109', - active_promotions: [], }); const newAccountData = accountFactory.build({ - company: 'New company_name', - email: 'new_test_email@linode.com', - first_name: 'NewFirstName', - last_name: 'New Last Name', address_1: 'new terrible address address for test', address_2: 'new Very long address for test Very long address for test Ve ', city: 'New Philadelphia', + company: 'New company_name', country: 'FR', + email: 'new_test_email@linode.com', + first_name: 'NewFirstName', + last_name: 'New Last Name', phone: '6104444444', state: 'Pennsylvania', tax_id: '9234567890', @@ -125,79 +127,49 @@ describe('Billing Contact', () => { .findByTitle('Edit Billing Contact Info') .should('be.visible') .within(() => { - cy.findByLabelText('First Name') - .should('be.visible') - .click() - .clear() - .type(newAccountData['first_name']); - cy.findByLabelText('Last Name') - .should('be.visible') - .click() - .clear() - .type(newAccountData['last_name']); - cy.findByLabelText('Company Name') - .should('be.visible') - .click() - .clear() - .type(newAccountData['company']); - cy.findByLabelText('Address') - .should('be.visible') - .click() - .clear() - .type(newAccountData['address_1']); - cy.findByLabelText('Address 2') - .should('be.visible') - .click() - .clear() - .type(newAccountData['address_2']); - cy.findByLabelText('Email (required)') - .should('be.visible') - .click() - .clear() - .type(newAccountData['email']); - cy.findByLabelText('City') - .should('be.visible') - .click() - .clear() - .type(newAccountData['city']); - cy.findByLabelText('Postal Code') - .should('be.visible') - .click() - .clear() - .type(newAccountData['zip']); - cy.findByLabelText('Phone') - .should('be.visible') - .click() - .clear() - .type(newAccountData['phone']); - ui.autocomplete - .findByLabel('State') - .should('be.visible') - .click() - .type(`${newAccountData['state']}`); + cy.findByLabelText('First Name').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(newAccountData['first_name']); + cy.findByLabelText('Last Name').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(newAccountData['last_name']); + cy.findByLabelText('Company Name').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(newAccountData['company']); + cy.findByLabelText('Address').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(newAccountData['address_1']); + cy.findByLabelText('Address 2').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(newAccountData['address_2']); + cy.findByLabelText('Email (required)').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(newAccountData['email']); + cy.findByLabelText('City').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(newAccountData['city']); + cy.findByLabelText('Postal Code').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(newAccountData['zip']); + cy.findByLabelText('Phone').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(newAccountData['phone']); + // need alias to be able to switch focus to modal popup + ui.autocomplete.findByLabel('State').should('be.visible').click(); + cy.focused().type(`${newAccountData['state']}`); ui.autocompletePopper .findByTitle(newAccountData['state']) .should('be.visible') .click(); - cy.findByLabelText('Tax ID') - .should('be.visible') - .click() - .clear() - .type(newAccountData['tax_id']); + cy.findByLabelText('Tax ID').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(newAccountData['tax_id']); cy.findByText(TAX_ID_HELPER_TEXT).should('not.exist'); - cy.get('[data-qa-save-contact-info="true"]') - .click() - .then(() => { - cy.wait('@updateAccount').then((xhr) => { - expect(xhr.response?.body).to.eql(newAccountData); - }); - }); + cy.get('[data-qa-save-contact-info="true"]').click(); + cy.wait('@updateAccount').then((xhr) => { + expect(xhr.response?.body).to.eql(newAccountData); + }); }); - - // check the page updates to reflect the edits - cy.get('[data-qa-contact-summary]').within(() => { - checkAccountContactDisplay(newAccountData); - }); }); it('Edit Contact Info: Tax ID Agreement', () => { @@ -222,48 +194,36 @@ describe('Billing Contact', () => { .findByTitle('Edit Billing Contact Info') .should('be.visible') .within(() => { - cy.findByLabelText('City') - .should('be.visible') - .click() - .clear() - .type(newAccountData['city']); - cy.findByLabelText('Postal Code') - .should('be.visible') - .click() - .clear() - .type(newAccountData['zip']); - ui.autocomplete - .findByLabel('Country') - .should('be.visible') - .click() - .type('Afghanistan'); + cy.findByLabelText('City').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(newAccountData['city']); + cy.findByLabelText('Postal Code').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(newAccountData['zip']); + ui.autocomplete.findByLabel('Country').should('be.visible').click(); + cy.focused().type('Afghanistan'); ui.autocompletePopper .findByTitle('Afghanistan') .should('be.visible') .click(); - cy.findByLabelText('Tax ID') - .should('be.visible') - .click() - .clear() - .type(newAccountData['tax_id']); + cy.findByLabelText('Tax ID').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(newAccountData['tax_id']); cy.findByText(TAX_ID_HELPER_TEXT).should('be.visible'); - cy.findByText(TAX_ID_AGREEMENT_TEXT) - .scrollIntoView() - .should('be.visible'); + cy.findByText(TAX_ID_AGREEMENT_TEXT).scrollIntoView(); + cy.findByText(TAX_ID_AGREEMENT_TEXT).should('be.visible'); cy.findByText('Akamai Privacy Statement.').should('be.visible'); cy.get('[data-qa-save-contact-info="true"]').should('be.disabled'); cy.get('[data-testid="tax-id-checkbox"]').click(); cy.get('[data-qa-save-contact-info="true"]') .should('be.enabled') - .click() - .then(() => { - cy.wait('@updateAccount').then((xhr) => { - expect(xhr.response?.body).to.eql(newAccountData); - }); - cy.wait('@updateAccountAgreements').then((xhr) => { - expect(xhr.response?.body).to.eql(newAccountAgreement); - }); - }); + .click(); + cy.wait('@updateAccount').then((xhr) => { + expect(xhr.response?.body).to.eql(newAccountData); + }); + cy.wait('@updateAccountAgreements').then((xhr) => { + expect(xhr.response?.body).to.eql(newAccountAgreement); + }); }); // check the page updates to reflect the edits diff --git a/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts b/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts index 2f33d308bc2..b00ce254e9e 100644 --- a/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/billing-invoices.spec.ts @@ -2,7 +2,6 @@ * @file Integration tests for account invoice functionality. */ -import type { InvoiceItem, TaxSummary } from '@linode/api-v4'; import { invoiceFactory, invoiceItemFactory } from '@src/factories'; import { DateTime } from 'luxon'; import { MAGIC_DATE_THAT_DC_SPECIFIC_PRICING_WAS_IMPLEMENTED } from 'support/constants/dc-specific-pricing'; @@ -16,6 +15,8 @@ import { formatUsd } from 'support/util/currency'; import { randomItem, randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion, getRegionById } from 'support/util/regions'; +import type { InvoiceItem, TaxSummary } from '@linode/api-v4'; + /** * Returns a string representation of a region, as shown on the invoice details page. * @@ -56,17 +57,17 @@ describe('Account invoices', () => { return invoiceItemFactory.build({ amount: subtotal, - tax, - total: subtotal + tax, from: DateTime.now().minus({ days: i }).toISO(), - to: DateTime.now().minus({ days: i }).plus({ hours }).toISO(), - quantity, - region: chooseRegion().id, - unit_price: `${randomNumber(5, 300) / 10000}`, label: `${itemType} ${randomNumber( 1, 24 )}GB - ${randomLabel()} (${randomNumber(10000, 99999)})`, + quantity, + region: chooseRegion().id, + tax, + to: DateTime.now().minus({ days: i }).plus({ hours }).toISO(), + total: subtotal + tax, + unit_price: `${randomNumber(5, 300) / 10000}`, }); }); @@ -75,9 +76,9 @@ describe('Account invoices', () => { ...mockInvoiceItemsWithRegions, invoiceItemFactory.build({ amount: 5, - total: 6, region: null, tax: 1, + total: 6, }), ]; @@ -111,10 +112,10 @@ describe('Account invoices', () => { // Create an Invoice object to correspond with the Invoice Items and their // charges. const mockInvoice = invoiceFactory.build({ + date: MAGIC_DATE_THAT_DC_SPECIFIC_PRICING_WAS_IMPLEMENTED, id: randomNumber(10000, 99999), - tax: sumTax, subtotal: sumSubtotal, - total: sumTax + sumSubtotal, + tax: sumTax, tax_summary: [ { name: 'PA STATE TAX', @@ -125,7 +126,7 @@ describe('Account invoices', () => { tax: Math.ceil(sumTax / 2), }, ], - date: MAGIC_DATE_THAT_DC_SPECIFIC_PRICING_WAS_IMPLEMENTED, + total: sumTax + sumSubtotal, }); // All mocked invoice items. @@ -250,8 +251,8 @@ describe('Account invoices', () => { it('does not list the region on past invoices', () => { const mockInvoice = invoiceFactory.build({ - id: randomNumber(), date: '2023-09-30 00:00:00Z', + id: randomNumber(), }); // Regular invoice items. diff --git a/packages/manager/cypress/e2e/core/billing/credit-card-expired-banner.spec.ts b/packages/manager/cypress/e2e/core/billing/credit-card-expired-banner.spec.ts index 148d8c270bd..2ed6fa23839 100644 --- a/packages/manager/cypress/e2e/core/billing/credit-card-expired-banner.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/credit-card-expired-banner.spec.ts @@ -1,8 +1,9 @@ -import { accountFactory } from 'src/factories'; import { mockGetAccount } from 'support/intercepts/account'; import { mockGetUserPreferences } from 'support/intercepts/profile'; import { ui } from 'support/ui'; +import { accountFactory } from 'src/factories'; + const creditCardExpiredBannerNotice = 'Your credit card has expired! Please update your payment details.'; diff --git a/packages/manager/cypress/e2e/core/billing/default-payment-method.spec.ts b/packages/manager/cypress/e2e/core/billing/default-payment-method.spec.ts index 5378475269f..a1722991cd1 100644 --- a/packages/manager/cypress/e2e/core/billing/default-payment-method.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/default-payment-method.spec.ts @@ -1,30 +1,31 @@ -import type { CreditCardData } from '@linode/api-v4'; import { paymentMethodFactory } from '@src/factories/accountPayment'; import { - mockSetDefaultPaymentMethod, mockGetPaymentMethods, + mockSetDefaultPaymentMethod, } from 'support/intercepts/account'; import { ui } from 'support/ui'; +import type { CreditCardData } from '@linode/api-v4'; + const paymentMethodGpay = (isDefault: boolean) => { return paymentMethodFactory.build({ + data: { card_type: 'Visa', expiry: '07/2025', last_four: '2045' }, id: 434357, - type: 'google_pay', is_default: isDefault, - data: { card_type: 'Visa', last_four: '2045', expiry: '07/2025' }, + type: 'google_pay', }); }; const paymentMethodCC = (isDefault: boolean) => { return paymentMethodFactory.build({ - id: 420330, - type: 'credit_card', - is_default: isDefault, data: { card_type: 'American Express', - last_four: '2222', expiry: '07/2025', + last_four: '2222', }, + id: 420330, + is_default: isDefault, + type: 'credit_card', }); }; diff --git a/packages/manager/cypress/e2e/core/billing/google-pay.spec.ts b/packages/manager/cypress/e2e/core/billing/google-pay.spec.ts index 743cc24e784..3c484ee5154 100644 --- a/packages/manager/cypress/e2e/core/billing/google-pay.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/google-pay.spec.ts @@ -1,25 +1,26 @@ import { mockGetPaymentMethods } from 'support/intercepts/account'; -import { PaymentMethod, CreditCardData } from '@linode/api-v4'; import { ui } from 'support/ui'; +import type { CreditCardData, PaymentMethod } from '@linode/api-v4'; + const mockPaymentMethods: PaymentMethod[] = [ { - id: 420330, - type: 'credit_card', - is_default: true, created: '2021-07-27T14:37:43', data: { card_type: 'American Express', - last_four: '2222', expiry: '07/2025', + last_four: '2222', }, + id: 420330, + is_default: true, + type: 'credit_card', }, { + created: '2021-08-04T18:29:01', + data: { card_type: 'Visa', expiry: '07/2025', last_four: '2045' }, id: 434357, - type: 'google_pay', is_default: false, - created: '2021-08-04T18:29:01', - data: { card_type: 'Visa', last_four: '2045', expiry: '07/2025' }, + type: 'google_pay', }, ]; @@ -31,27 +32,28 @@ const mockPaymentMethodsData = mockPaymentMethods.map( const mockPaymentMethodsExpired: PaymentMethod[] = [ { - id: 420330, - type: 'credit_card', - is_default: true, created: '2021-07-27T14:37:43', data: { card_type: 'American Express', - last_four: '2222', expiry: '07/2025', + last_four: '2222', }, + id: 420330, + is_default: true, + type: 'credit_card', }, { + created: '2021-08-04T18:29:01', + data: { card_type: 'Visa', expiry: '07/2020', last_four: '2045' }, id: 434357, - type: 'google_pay', is_default: false, - created: '2021-08-04T18:29:01', - data: { card_type: 'Visa', last_four: '2045', expiry: '07/2020' }, + type: 'google_pay', }, ]; const pastDueExpiry = 'Expired 07/20'; -const braintreeURL = 'https://client-analytics.braintreegateway.com/*'; +const braintreeURL = + 'https://+(payments.braintree-api.com|payments.sandbox.braintree-api.com)/*'; describe('Google Pay', () => { it('adds google pay method', () => { diff --git a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts index 6869a54a91b..4c55cefadfa 100644 --- a/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/restricted-user-billing.spec.ts @@ -5,7 +5,6 @@ import { paymentMethodFactory, profileFactory } from '@src/factories'; import { accountUserFactory } from '@src/factories/accountUsers'; import { grantsFactory } from '@src/factories/grants'; -import { ADMINISTRATOR, PARENT_USER } from 'src/features/Account/constants'; import { mockGetPaymentMethods, mockGetUser } from 'support/intercepts/account'; import { mockGetProfile, @@ -14,6 +13,8 @@ import { import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; +import { ADMINISTRATOR, PARENT_USER } from 'src/features/Account/constants'; + // Tooltip message that appears on disabled billing action buttons for restricted // and child users. const restrictedUserTooltip = @@ -234,14 +235,14 @@ describe('restricted user billing flows', () => { */ it('cannot edit billing information with read-only account access', () => { const mockProfile = profileFactory.build({ - username: randomLabel(), restricted: true, + username: randomLabel(), }); const mockUser = accountUserFactory.build({ - username: mockProfile.username, restricted: true, user_type: 'default', + username: mockProfile.username, }); const mockGrants = grantsFactory.build({ @@ -273,8 +274,8 @@ describe('restricted user billing flows', () => { */ it('cannot edit billing information as child account', () => { const mockProfile = profileFactory.build({ - username: randomLabel(), user_type: 'child', + username: randomLabel(), }); const mockUser = accountUserFactory.build({ @@ -299,25 +300,25 @@ describe('restricted user billing flows', () => { */ it('can edit billing information as a regular user and as a parent user', () => { const mockProfileRegular = profileFactory.build({ - username: randomLabel(), restricted: false, + username: randomLabel(), }); const mockUserRegular = accountUserFactory.build({ - username: mockProfileRegular.username, - user_type: 'default', restricted: false, + user_type: 'default', + username: mockProfileRegular.username, }); const mockProfileParent = profileFactory.build({ - username: randomLabel(), restricted: false, + username: randomLabel(), }); const mockUserParent = accountUserFactory.build({ - username: mockProfileParent.username, - user_type: 'parent', restricted: false, + user_type: 'parent', + username: mockProfileParent.username, }); // Confirm button behavior for regular users. diff --git a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts index 4a1f57eeb9b..9189a234d17 100644 --- a/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts +++ b/packages/manager/cypress/e2e/core/billing/smoke-billing-activity.spec.ts @@ -1,19 +1,21 @@ -import { DateTime } from 'luxon'; import { getProfile } from '@linode/api-v4'; -import type { Invoice, Profile, Payment } from '@linode/api-v4'; -import { invoiceFactory, paymentFactory } from 'src/factories/billing'; +import { profileFactory } from '@src/factories'; +import { formatDate } from '@src/utilities/formatDate'; +import { DateTime } from 'luxon'; import { authenticate } from 'support/api/authentication'; import { mockGetInvoices, - mockGetPayments, mockGetPaymentMethods, + mockGetPayments, } from 'support/intercepts/account'; -import { formatDate } from '@src/utilities/formatDate'; -import { randomNumber } from 'support/util/random'; -import { ui } from 'support/ui'; -import { profileFactory } from '@src/factories'; import { mockGetProfile, mockUpdateProfile } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; import { buildArray } from 'support/util/arrays'; +import { randomNumber } from 'support/util/random'; + +import { invoiceFactory, paymentFactory } from 'src/factories/billing'; + +import type { Invoice, Payment, Profile } from '@linode/api-v4'; /** * Uses the user menu to navigate to the Profile Display page. @@ -67,8 +69,8 @@ const navigateToBilling = () => { */ const assertInvoiceInfo = (invoice: Invoice, timezone: string) => { const invoiceDate = formatDate(invoice.date, { - timezone, displayTime: true, + timezone, }); cy.findByText(invoice.label) .should('be.visible') @@ -97,8 +99,8 @@ const assertInvoiceInfo = (invoice: Invoice, timezone: string) => { */ const assertPaymentInfo = (payment: Payment, timezone: string) => { const paymentDate = formatDate(payment.date, { - timezone, displayTime: true, + timezone, }); cy.findByText(`Payment #${payment.id}`) .should('be.visible') @@ -132,9 +134,9 @@ describe('Billing Activity Feed', () => { const tax = randomNumber(5, 50); return invoiceFactory.build({ + date, id, label: `Invoice #${id}`, - date, subtotal, tax, total: subtotal + tax, @@ -148,8 +150,8 @@ describe('Billing Activity Feed', () => { const date = DateTime.now().minus({ months: i }).toISO(); return paymentFactory.build({ - id, date, + id, usd: invoice.total, }); } @@ -167,8 +169,9 @@ describe('Billing Activity Feed', () => { cy.visitWithLogin('/account/billing'); cy.wait(['@getInvoices', '@getPayments']); cy.findByText('Billing & Payment History') - .scrollIntoView() - .should('be.visible'); + .as('qaBilling') + .scrollIntoView(); + cy.get('@qaBilling').should('be.visible'); // Confirm that payments and invoices from the past 6 months are displayed, // and that payments and invoices beyond 6 months are not displayed. @@ -196,7 +199,8 @@ describe('Billing Activity Feed', () => { mockGetInvoices(invoiceMocks).as('getInvoices'); mockGetPayments(paymentMocks).as('getPayments'); - cy.findByText('Transaction Dates').click().type(`All Time`); + cy.findByText('Transaction Dates').click(); + cy.focused().type(`All Time`); ui.autocompletePopper .findByTitle(`All Time`) .should('be.visible') @@ -214,7 +218,8 @@ describe('Billing Activity Feed', () => { }); // Change transaction type drop-down to "Payments" only. - cy.findByText('Transaction Types').click().type(`Payments`); + cy.findByText('Transaction Types').click(); + cy.focused().type(`Payments`); ui.autocompletePopper .findByTitle(`Payments`) .should('be.visible') @@ -268,7 +273,8 @@ describe('Billing Activity Feed', () => { cy.wait(['@getInvoices', '@getPayments', '@getPaymentMethods']); // Change invoice date selection from "6 Months" to "All Time". - cy.findByText('Transaction Dates').click().type('All Time'); + cy.findByText('Transaction Dates').click(); + cy.focused().type('All Time'); ui.autocompletePopper.findByTitle('All Time').should('be.visible').click(); cy.get('[data-qa-billing-activity-panel]') @@ -328,9 +334,9 @@ describe('Billing Activity Feed', () => { it('displays correct timezone for invoice and payment dates', () => { // Time zones against which to verify invoice and payment dates. const timeZonesList = [ - { key: 'America/New_York', human: 'Eastern Time - New York' }, - { key: 'UTC', human: 'Coordinated Universal Time' }, - { key: 'Asia/Hong_Kong', human: 'Hong Kong Standard Time' }, + { human: 'Eastern Time - New York', key: 'America/New_York' }, + { human: 'Coordinated Universal Time', key: 'UTC' }, + { human: 'Hong Kong Standard Time', key: 'Asia/Hong_Kong' }, ]; const mockProfile = profileFactory.build({ @@ -372,10 +378,8 @@ describe('Billing Activity Feed', () => { // This isn't strictly necessary, but is the most straightforward way to // get Cloud to re-fetch the user's profile data with the new timezone // applied. - cy.findByText('Timezone') - .should('be.visible') - .click() - .type(`${timezoneLabel}{enter}`); + cy.findByText('Timezone').should('be.visible').click(); + cy.focused().type(`${timezoneLabel}{enter}`); ui.button .findByTitle('Update Timezone') diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-errors.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-errors.spec.ts new file mode 100644 index 00000000000..0163f19851c --- /dev/null +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-errors.spec.ts @@ -0,0 +1,100 @@ +import { mockGetAccount } from 'support/intercepts/account'; +import { + mockGetAllAlertDefinitions, + mockGetCloudPulseServices, + mockUpdateAlertDefinitionsError, +} from 'support/intercepts/cloudpulse'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { ui } from 'support/ui'; + +import { accountFactory, alertFactory } from 'src/factories'; + +import type { Flags } from 'src/featureFlags'; + +const flags: Partial = { aclp: { beta: true, enabled: true } }; +const mockAccount = accountFactory.build(); +const mockAlerts = [ + alertFactory.build({ + label: 'Alert-1', + service_type: 'dbaas', + status: 'enabled', + type: 'user', + }), + alertFactory.build({ + label: 'Alert-2', + service_type: 'dbaas', + status: 'disabled', + type: 'user', + }), +]; + +describe('Alerts Listing Page - Error Handling', () => { + /** + * + * - Confirms that users can attempt to enable or disable alerts from the Alerts Listing page. + * - Confirms that API failures when updating an alert are handled correctly. + * - Confirms that an error message is displayed in the UI when an alert update fails. + * - Confirms that the error message appears in a toast notification. + * - Confirms that users remain on the Alerts Listing page even after an update failure. + * - Confirms that the UI does not reflect a successful state change if the request fails. + */ + beforeEach(() => { + mockAppendFeatureFlags(flags); + mockGetAccount(mockAccount); + mockGetCloudPulseServices(['linode', 'dbaas']); + mockGetAllAlertDefinitions(mockAlerts).as('getAlertDefinitionsList'); + mockUpdateAlertDefinitionsError( + 'dbaas', + 1, + 'An error occurred while disabling the alert' + ).as('getFirstAlertDefinitions'); + mockUpdateAlertDefinitionsError( + 'dbaas', + 2, + 'An error occurred while enabling the alert' + ).as('getSecondAlertDefinitions'); + cy.visitWithLogin('/alerts/definitions'); + cy.wait('@getAlertDefinitionsList'); + }); + + it('should display correct error messages when disabling or enabling alerts fails', () => { + // Function to search for an alert + const searchAlert = (alertName: string) => { + cy.findByPlaceholderText('Search for Alerts') + .should('be.visible') + .and('not.be.disabled') + .clear(); + + cy.findByPlaceholderText('Search for Alerts').type(alertName); + }; + + // Function to toggle an alert's status + const toggleAlertStatus = ( + alertName: string, + action: 'Disable' | 'Enable', + alias: string + ) => { + cy.findByText(alertName) + .should('be.visible') + .closest('tr') + .within(() => { + ui.actionMenu + .findByTitle(`Action menu for Alert ${alertName}`) + .should('be.visible') + .click(); + }); + + ui.actionMenuItem.findByTitle(action).should('be.visible').click(); + cy.wait(alias).then(({ response }) => { + ui.toast.assertMessage(response?.body.errors[0].reason); + }); + }; + // Disable "Alert-1" + searchAlert('Alert-1'); + toggleAlertStatus('Alert-1', 'Disable', '@getFirstAlertDefinitions'); + + // Enable "Alert-2" + searchAlert('Alert-2'); + toggleAlertStatus('Alert-2', 'Enable', '@getSecondAlertDefinitions'); + }); +}); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts index 2febd83fe46..cc1eb5f9dbf 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts @@ -4,7 +4,24 @@ * This file contains Cypress tests that validate the display and content of the Alerts Show Detail Page in the CloudPulse application. * It ensures that all alert details, criteria, and resource information are displayed correctly. */ +import { capitalize } from '@linode/utilities'; +import { + aggregationTypeMap, + dimensionOperatorTypeMap, + metricOperatorTypeMap, + severityMap, +} from 'support/constants/alert'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + mockGetAlertChannels, + mockGetAlertDefinitions, + mockGetAllAlertDefinitions, +} from 'support/intercepts/cloudpulse'; +import { mockGetDatabases } from 'support/intercepts/databases'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; + import { accountFactory, alertFactory, @@ -13,66 +30,51 @@ import { notificationChannelFactory, regionFactory, } from 'src/factories'; -import { mockGetAccount } from 'support/intercepts/account'; -import type { Flags } from 'src/featureFlags'; - -import { - mockGetAlertChannels, - mockGetAlertDefinitions, - mockGetAllAlertDefinitions, -} from 'support/intercepts/cloudpulse'; -import { mockGetRegions } from 'support/intercepts/regions'; import { formatDate } from 'src/utilities/formatDate'; -import { - metricOperatorTypeMap, - dimensionOperatorTypeMap, - severityMap, - aggregationTypeMap, -} from 'support/constants/alert'; -import { ui } from 'support/ui'; -import { Database } from '@linode/api-v4'; -import { mockGetDatabases } from 'support/intercepts/databases'; -const flags: Partial = { aclp: { enabled: true, beta: true } }; +import type { Database } from '@linode/api-v4'; +import type { Flags } from 'src/featureFlags'; + +const flags: Partial = { aclp: { beta: true, enabled: true } }; const mockAccount = accountFactory.build(); const regions = [ regionFactory.build({ capabilities: ['Managed Databases'], + country: 'us', id: 'us-ord', label: 'Chicago, IL', - country: 'us', }), regionFactory.build({ capabilities: ['Managed Databases'], + country: 'us', id: 'us-east', label: 'Newark', - country: 'us', }), ]; const databases: Database[] = databaseFactory.buildList(5).map((db, index) => ({ ...db, - type: 'MySQL', - region: regions[index % regions.length].id, engine: 'mysql', + region: regions[index % regions.length].id, + type: 'MySQL', })); const alertDetails = alertFactory.build({ + entity_ids: databases.slice(0, 4).map((db) => db.id.toString()), + rule_criteria: { rules: alertRulesFactory.buildList(2) }, service_type: 'dbaas', severity: 1, status: 'enabled', type: 'system', - entity_ids: databases.slice(0, 4).map((db) => db.id.toString()), - rule_criteria: { rules: alertRulesFactory.buildList(2) }, }); const { - service_type, - severity, - rule_criteria, + created_by, + description, id, label, - description, - created_by, + rule_criteria, + service_type, + severity, updated, } = alertDetails; const { rules } = rule_criteria; @@ -107,7 +109,7 @@ describe('Integration Tests for Alert Show Detail Page', () => { it('navigates to the Show Details page from the list page', () => { // Navigate to the alert definitions list page with login - cy.visitWithLogin('/monitor/alerts/definitions'); + cy.visitWithLogin('/alerts/definitions'); // Wait for the alert definitions list API call to complete cy.wait('@getAlertDefinitionsList'); @@ -131,9 +133,7 @@ describe('Integration Tests for Alert Show Detail Page', () => { }); it('should correctly display the details of the DBaaS alert in the alert details view', () => { - cy.visitWithLogin( - `/monitor/alerts/definitions/detail/${service_type}/${id}` - ); + cy.visitWithLogin(`/alerts/definitions/detail/${service_type}/${id}`); cy.wait(['@getDBaaSAlertDefinitions', '@getMockedDbaasDatabases']); // Validating contents of Overview Section @@ -225,10 +225,10 @@ describe('Integration Tests for Alert Show Detail Page', () => { ); }); // Validate the filter value - cy.get(`[data-qa-chip="${filter.value}"]`) + cy.get(`[data-qa-chip="${capitalize(filter.value)}"]`) .should('be.visible') .each(($chip) => { - expect($chip).to.have.text(filter.value); + expect($chip).to.have.text(capitalize(filter.value)); }); }); }); @@ -238,22 +238,22 @@ describe('Integration Tests for Alert Show Detail Page', () => { cy.get('[data-qa-item="Polling Interval"]') .find('[data-qa-chip]') .should('be.visible') - .should('have.text', '2 minutes'); + .should('have.text', '10 minutes'); // Validating contents of Evaluation Periods cy.get('[data-qa-item="Evaluation Period"]') .find('[data-qa-chip]') .should('be.visible') - .should('have.text', '4 minutes'); + .should('have.text', '5 minutes'); // Validating contents of Trigger Alert cy.get('[data-qa-chip="All"]') .should('be.visible') .should('have.text', 'All'); - cy.get('[data-qa-chip="4 minutes"]') + cy.get('[data-qa-chip="5 minutes"]') .should('be.visible') - .should('have.text', '4 minutes'); + .should('have.text', '5 minutes'); cy.get('[data-qa-item="criteria are met for"]') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts index 2cdabf80e0c..02392ba5670 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/alerts-listing-page.spec.ts @@ -2,60 +2,62 @@ * @file Integration Tests for the CloudPulse Alerts Listing Page. * This file verifies the UI, functionality, and sorting/filtering of the CloudPulse Alerts Listing Page. */ -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { accountFactory, alertFactory } from 'src/factories'; +import { cloudPulseServiceMap } from 'support/constants/cloudpulse'; import { mockGetAccount } from 'support/intercepts/account'; -import type { Flags } from 'src/featureFlags'; import { mockGetAllAlertDefinitions, mockGetCloudPulseServices, mockUpdateAlertDefinitions, } from 'support/intercepts/cloudpulse'; -import { formatDate } from 'src/utilities/formatDate'; -import { Alert } from '@linode/api-v4'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; + +import { accountFactory, alertFactory } from 'src/factories'; import { alertStatuses } from 'src/features/CloudPulse/Alerts/constants'; -import { cloudPulseServiceMap } from 'support/constants/cloudpulse'; +import { formatDate } from 'src/utilities/formatDate'; -const flags: Partial = { aclp: { enabled: true, beta: true } }; +import type { Alert } from '@linode/api-v4'; +import type { Flags } from 'src/featureFlags'; + +const flags: Partial = { aclp: { beta: true, enabled: true } }; const mockAccount = accountFactory.build(); const now = new Date(); const mockAlerts = [ alertFactory.build({ + created_by: 'user1', + label: 'Alert-1', service_type: 'dbaas', severity: 1, status: 'enabled', type: 'user', - created_by: 'user1', updated: new Date(now.getTime() - 86400).toISOString(), - label: 'Alert-1', }), alertFactory.build({ + created_by: 'user4', + label: 'Alert-2', service_type: 'dbaas', - type: 'user', severity: 0, status: 'disabled', + type: 'user', updated: new Date(now.getTime() - 10 * 86400).toISOString(), - created_by: 'user4', - label: 'Alert-2', }), alertFactory.build({ + created_by: 'user2', + label: 'Alert-3', service_type: 'linode', - type: 'user', severity: 2, status: 'enabled', + type: 'user', updated: new Date(now.getTime() - 6 * 86400).toISOString(), - created_by: 'user2', - label: 'Alert-3', }), alertFactory.build({ + created_by: 'user3', + label: 'Alert-4', service_type: 'linode', severity: 3, status: 'disabled', type: 'user', updated: new Date(now.getTime() - 4 * 86400).toISOString(), - created_by: 'user3', - label: 'Alert-4', }), ]; @@ -93,10 +95,8 @@ const verifyTableSorting = ( sortOrder: 'ascending' | 'descending', expectedValues: number[] ) => { - ui.heading - .findByText(header) - .click() - .should('have.attr', 'aria-sort', sortOrder); + ui.heading.findByText(header).click(); + ui.heading.findByText(header).should('have.attr', 'aria-sort', sortOrder); cy.get('[data-qa="alert-table"]').within(() => { cy.get('[data-qa-alert-cell]').should(($cells) => { @@ -116,7 +116,7 @@ const verifyTableSorting = ( * @param {Alert} alert - The alert object to validate. */ const validateAlertDetails = (alert: Alert) => { - const { id, service_type, status, label, updated, created_by } = alert; + const { created_by, id, label, service_type, status, updated } = alert; cy.get(`[data-qa-alert-cell="${id}"]`).within(() => { cy.findByText(cloudPulseServiceMap[service_type]) @@ -132,7 +132,7 @@ const validateAlertDetails = (alert: Alert) => { .and( 'have.attr', 'href', - `/monitor/alerts/definitions/detail/${service_type}/${id}` + `/alerts/definitions/detail/${service_type}/${id}` ); cy.findByText(formatDate(updated, { format: 'MMM dd, yyyy, h:mm a' })) .should('be.visible') @@ -164,28 +164,28 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { mockUpdateAlertDefinitions('dbaas', 2, mockAlerts[1]).as( 'getSecondAlertDefinitions' ); - cy.visitWithLogin('/monitor/alerts/definitions'); + cy.visitWithLogin('/alerts/definitions'); cy.wait('@getAlertDefinitionsList'); }); it('should verify sorting functionality for multiple columns in ascending and descending order', () => { const sortCases = [ - { column: 'label', descending: [4, 3, 2, 1], ascending: [1, 2, 3, 4] }, - { column: 'status', descending: [1, 3, 2, 4], ascending: [2, 4, 1, 3] }, + { ascending: [1, 2, 3, 4], column: 'label', descending: [4, 3, 2, 1] }, + { ascending: [2, 4, 1, 3], column: 'status', descending: [1, 3, 2, 4] }, { + ascending: [2, 1, 4, 3], column: 'service_type', descending: [4, 3, 2, 1], - ascending: [2, 1, 4, 3], }, { + ascending: [1, 3, 4, 2], column: 'created_by', descending: [2, 4, 3, 1], - ascending: [1, 3, 4, 2], }, - { column: 'updated', descending: [1, 4, 3, 2], ascending: [2, 3, 4, 1] }, + { ascending: [2, 3, 4, 1], column: 'updated', descending: [1, 4, 3, 2] }, ]; - sortCases.forEach(({ column, descending, ascending }) => { + sortCases.forEach(({ ascending, column, descending }) => { // Verify descending order verifyTableSorting(column, 'descending', descending); @@ -196,12 +196,11 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { it('should validate UI elements and alert details', () => { // Validate navigation links and buttons - cy.findByText('Alerts') - .should('be.visible') - .and('have.attr', 'href', '/monitor/alerts'); + cy.findByText('Alerts').should('be.visible'); + cy.findByText('Definitions') .should('be.visible') - .and('have.attr', 'href', '/monitor/alerts/definitions'); + .and('have.attr', 'href', '/alerts/definitions'); ui.buttonGroup.findButtonByTitle('Create Alert').should('be.visible'); // Validate table headers @@ -263,8 +262,8 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { ui.button .findByAttribute('aria-label', 'Clear') .should('be.visible') - .scrollIntoView() - .click(); + .scrollIntoView(); + ui.button.findByAttribute('aria-label', 'Clear').click(); }); // Filter by alert status and validate the results @@ -296,8 +295,8 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { cy.findByPlaceholderText('Search for Alerts') .should('be.visible') .and('not.be.disabled') - .clear() - .type(alertName); + .clear(); + cy.findByPlaceholderText('Search for Alerts').type(alertName); cy.focused().click(); }; @@ -305,7 +304,7 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { // Function to toggle an alert's status const toggleAlertStatus = ( alertName: string, - action: 'Enable' | 'Disable', + action: 'Disable' | 'Enable', alias: string, successMessage: string ) => { diff --git a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts index c3efa4d24e7..663108e3b42 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-dashboard-errors.spec.ts @@ -1,21 +1,32 @@ /** * @file Error Handling Tests for CloudPulse Dashboard. */ -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { widgetDetails } from 'support/constants/widgets'; +import { mockGetAccount } from 'support/intercepts/account'; import { mockCreateCloudPulseJWEToken, mockGetCloudPulseDashboard, - mockGetCloudPulseDashboards, - mockGetCloudPulseMetricDefinitions, - mockGetCloudPulseServices, mockGetCloudPulseDashboardByIdError, + mockGetCloudPulseDashboards, mockGetCloudPulseDashboardsError, + mockGetCloudPulseMetricDefinitions, mockGetCloudPulseMetricDefinitionsError, + mockGetCloudPulseServices, mockGetCloudPulseServicesError, mockGetCloudPulseTokenError, } from 'support/intercepts/cloudpulse'; +import { + mockGetDatabases, + mockGetDatabasesError, +} from 'support/intercepts/databases'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetUserPreferences } from 'support/intercepts/profile'; +import { + mockGetRegions, + mockGetRegionsError, +} from 'support/intercepts/regions'; import { ui } from 'support/ui'; -import { widgetDetails } from 'support/constants/widgets'; + import { accountFactory, dashboardFactory, @@ -24,18 +35,9 @@ import { regionFactory, widgetFactory, } from 'src/factories'; -import { mockGetUserPreferences } from 'support/intercepts/profile'; -import { - mockGetRegions, - mockGetRegionsError, -} from 'support/intercepts/regions'; -import { - mockGetDatabases, - mockGetDatabasesError, -} from 'support/intercepts/databases'; -import { Database } from '@linode/api-v4'; -import { mockGetAccount } from 'support/intercepts/account'; -import { Flags } from 'src/featureFlags'; + +import type { Database } from '@linode/api-v4'; +import type { Flags } from 'src/featureFlags'; /** * Verifies the presence and values of specific properties within the aclpPreference object @@ -47,7 +49,7 @@ import { Flags } from 'src/featureFlags'; */ const flags: Partial = { - aclp: { enabled: true, beta: true }, + aclp: { beta: true, enabled: true }, aclpResourceTypeMap: [ { dimensionKey: 'LINODE_ID', @@ -64,29 +66,29 @@ const flags: Partial = { ], }; const { - metrics, - id, - serviceType, + clusterName, dashboardName, engine, - clusterName, + id, + metrics, nodeType, + serviceType, } = widgetDetails.dbaas; const dashboard = dashboardFactory.build({ label: dashboardName, service_type: serviceType, - widgets: metrics.map(({ title, yLabel, name, unit }) => { + widgets: metrics.map(({ name, title, unit, yLabel }) => { return widgetFactory.build({ label: title, - y_label: yLabel, metric: name, unit, + y_label: yLabel, }); }), }); -const metricDefinitions = metrics.map(({ title, name, unit }) => +const metricDefinitions = metrics.map(({ name, title, unit }) => dashboardMetricFactory.build({ label: title, metric: name, @@ -101,13 +103,13 @@ const mockRegion = regionFactory.build({ }); const databaseMock: Database = databaseFactory.build({ + cluster_size: 3, + engine: 'mysql', label: clusterName, - type: engine, region: mockRegion.id, - version: '1', status: 'provisioning', - cluster_size: 3, - engine: 'mysql', + type: engine, + version: '1', }); const mockAccount = accountFactory.build(); @@ -132,7 +134,7 @@ describe('Tests for API error handling', () => { 'Internal Server Error' ).as('getMetricDefinitions'); - cy.visitWithLogin('monitor/cloudpulse'); + cy.visitWithLogin('/metrics'); // Wait for the API calls . cy.wait(['@fetchServices', '@fetchDashboard']); @@ -148,7 +150,7 @@ describe('Tests for API error handling', () => { .should('be.visible') .click(); - //Select a Database Engine from the autocomplete input. + // Select a Database Engine from the autocomplete input. ui.autocomplete .findByLabel('Database Engine') .should('be.visible') @@ -194,7 +196,7 @@ describe('Tests for API error handling', () => { // Mocking an error response for the 'fetchServices' API request. mockGetCloudPulseServicesError('Internal Server Error').as('fetchServices'); - cy.visitWithLogin('monitor/cloudpulse'); + cy.visitWithLogin('/metrics'); // Wait for the API calls . cy.wait('@fetchServices'); @@ -209,7 +211,7 @@ describe('Tests for API error handling', () => { 'getCloudPulseTokenError' ); - cy.visitWithLogin('monitor/cloudpulse'); + cy.visitWithLogin('/metrics'); // Wait for the API calls . cy.wait(['@fetchServices', '@fetchDashboard']); @@ -275,7 +277,7 @@ describe('Tests for API error handling', () => { 'fetchDashboard' ); - cy.visitWithLogin('monitor/cloudpulse'); + cy.visitWithLogin('/metrics'); // Wait for the API calls . cy.wait(['@fetchServices', '@fetchDashboard']); @@ -292,7 +294,7 @@ describe('Tests for API error handling', () => { 'getCloudPulseDashboardError' ); - cy.visitWithLogin('monitor/cloudpulse'); + cy.visitWithLogin('/metrics'); // Wait for the API calls . cy.wait(['@fetchServices', '@fetchDashboard']); @@ -356,7 +358,7 @@ describe('Tests for API error handling', () => { 'getCloudPulseRegionsError' ); - cy.visitWithLogin('monitor/cloudpulse'); + cy.visitWithLogin('/metrics'); // Wait for the API calls . cy.wait(['@fetchServices', '@fetchDashboard']); @@ -386,7 +388,7 @@ describe('Tests for API error handling', () => { 'getDatabaseInstancesError' ); - cy.visitWithLogin('monitor/cloudpulse'); + cy.visitWithLogin('/metrics'); // Wait for the API calls . cy.wait(['@fetchServices', '@fetchDashboard']); @@ -410,7 +412,7 @@ describe('Tests for API error handling', () => { .should('be.visible') .click(); - //Select a Database Engine from the autocomplete input. + // Select a Database Engine from the autocomplete input. ui.autocomplete .findByLabel('Database Engine') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts index f83b057626e..e86622b72bf 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts @@ -1,24 +1,25 @@ /** - * @file Integration tests for CloudPulse navigation. + * @file Integration tests for Moniter navigation. */ -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetAccount } from 'support/intercepts/account'; -import { accountFactory } from 'src/factories'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; +import { accountFactory } from 'src/factories'; + const mockAccount = accountFactory.build(); -describe('CloudPulse navigation', () => { +describe('Moniter navigation', () => { beforeEach(() => { mockGetAccount(mockAccount).as('getAccount'); }); /* - * - Confirms that Cloudpulse navigation item is shown when feature flag is enabled. - * - Confirms that clicking Cloudpulse navigation item directs user to Cloudpulse landing page. + * - Confirms that Metrics navigation item is shown when feature flag is enabled. + * - Confirms that clicking Metrics navigation item directs user to Metrics landing page. */ - it('can navigate to Cloudpulse landing page', () => { + it('can navigate to metrics landing page', () => { mockAppendFeatureFlags({ aclp: { beta: true, @@ -29,14 +30,14 @@ describe('CloudPulse navigation', () => { cy.visitWithLogin('/linodes'); cy.wait('@getFeatureFlags'); - cy.get('[data-testid="menu-item-Monitor"]').should('be.visible').click(); - cy.url().should('endWith', '/monitor'); + cy.get('[data-testid="menu-item-Metrics"]').should('be.visible').click(); + cy.url().should('endWith', '/metrics'); }); /* - * - Confirms that Cloudpulse navigation item is not shown when feature flag is disabled. + * - Confirms that metrics navigation item is not shown when feature flag is disabled. */ - it('does not show Cloudpulse navigation item when feature is disabled', () => { + it('does not show metrics navigation item when feature is disabled', () => { mockAppendFeatureFlags({ aclp: { beta: true, @@ -48,14 +49,14 @@ describe('CloudPulse navigation', () => { cy.wait('@getFeatureFlags'); ui.nav.find().within(() => { - cy.get('[data-testid="menu-item-Monitor"]').should('not.exist'); + cy.get('[data-testid="menu-item-Metrics"]').should('not.exist'); }); }); /* - * - Confirms that manual navigation to Cloudpulse landing page with feature is disabled displays Not Found to user. + * - Confirms that manual navigation to metrics landing page with feature is disabled displays Not Found to user. */ - it('displays Not Found when manually navigating to /cloudpulse with feature flag disabled', () => { + it('displays Not Found when manually navigating to /metrics with feature flag disabled', () => { mockAppendFeatureFlags({ aclp: { beta: true, @@ -63,14 +64,14 @@ describe('CloudPulse navigation', () => { }, }).as('getFeatureFlags'); - cy.visitWithLogin('monitor'); + cy.visitWithLogin('/metrics'); cy.wait('@getFeatureFlags'); cy.findByText('Not Found').should('be.visible'); }); /* - * - Confirms that manual navigation to the 'Alert' page on the Cloudpulse landing page is disabled, and users are shown a 'Not Found' message.. + * - Confirms that manual navigation to the 'Alert' landing page is disabled, and users are shown a 'Not Found' message.. */ it('should display "Not Found" when navigating to alert definitions with feature flag disabled', () => { mockAppendFeatureFlags({ @@ -78,7 +79,7 @@ describe('CloudPulse navigation', () => { }).as('getFeatureFlags'); // Attempt to visit the alert definitions page for a specific alert using a manual URL - cy.visitWithLogin('monitor/alerts/definitions'); + cy.visitWithLogin('/alerts'); // Wait for the feature flag to be fetched and applied cy.wait('@getFeatureFlags'); @@ -88,7 +89,7 @@ describe('CloudPulse navigation', () => { }); /* - * - Confirms that manual navigation to the 'Alert Definitions Detail' page on the Cloudpulse landing page is disabled, and users are shown a 'Not Found' message.. + * - Confirms that manual navigation to the 'Alert Definitions Detail' page on the Alert landing page is disabled, and users are shown a 'Not Found' message.. */ it('should display "Not Found" when manually navigating to alert details with feature flag disabled', () => { mockAppendFeatureFlags({ @@ -96,7 +97,7 @@ describe('CloudPulse navigation', () => { }).as('getFeatureFlags'); // Attempt to visit the alert detail page for a specific alert using a manual URL - cy.visitWithLogin('monitor/alerts/definitions/detail/dbaas/20000'); + cy.visitWithLogin('/alerts/definitions/detail/dbaas/20000'); // Wait for the feature flag to be fetched and applied cy.wait('@getFeatureFlags'); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts new file mode 100644 index 00000000000..7b5fdff0cb1 --- /dev/null +++ b/packages/manager/cypress/e2e/core/cloudpulse/create-user-alert.spec.ts @@ -0,0 +1,424 @@ +/** + * @fileoverview Cypress test suite for the "Create Alert" functionality. + */ + +import { statusMap } from 'support/constants/alert'; +import { widgetDetails } from 'support/constants/widgets'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + mockCreateAlertDefinition, + mockGetAlertChannels, + mockGetAllAlertDefinitions, + mockGetCloudPulseMetricDefinitions, + mockGetCloudPulseServices, +} from 'support/intercepts/cloudpulse'; +import { mockGetDatabases } from 'support/intercepts/databases'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; + +import { + accountFactory, + alertDefinitionFactory, + alertFactory, + cpuRulesFactory, + dashboardMetricFactory, + databaseFactory, + memoryRulesFactory, + notificationChannelFactory, + regionFactory, + triggerConditionFactory, +} from 'src/factories'; +import { formatDate } from 'src/utilities/formatDate'; + +import type { Flags } from 'src/featureFlags'; + +export interface MetricDetails { + aggregationType: string; + dataField: string; + operator: string; + ruleIndex: number; + threshold: string; +} + +const flags: Partial = { aclp: { beta: true, enabled: true } }; + +// Create mock data +const mockAccount = accountFactory.build(); +const mockRegion = regionFactory.build({ + capabilities: ['Managed Databases'], + id: 'us-ord', + label: 'Chicago, IL', +}); +const { metrics, serviceType } = widgetDetails.dbaas; +const databaseMock = databaseFactory.buildList(10, { + cluster_size: 3, + engine: 'mysql', + region: 'us-ord', +}); + +const notificationChannels = notificationChannelFactory.build({ + channel_type: 'email', + id: 1, + label: 'channel-1', + type: 'custom', +}); + +const customAlertDefinition = alertDefinitionFactory.build({ + channel_ids: [1], + description: 'My Custom Description', + entity_ids: ['2'], + label: 'Alert-1', + rule_criteria: { + rules: [cpuRulesFactory.build(), memoryRulesFactory.build()], + }, + severity: 0, + tags: [''], + trigger_conditions: triggerConditionFactory.build(), +}); + +const metricDefinitions = metrics.map(({ name, title, unit }) => + dashboardMetricFactory.build({ + label: title, + metric: name, + unit, + }) +); +const mockAlerts = alertFactory.build({ + alert_channels: [{ id: 1 }], + created_by: 'user1', + description: 'My Custom Description', + entity_ids: ['2'], + label: 'Alert-1', + rule_criteria: { + rules: [cpuRulesFactory.build(), memoryRulesFactory.build()], + }, + service_type: 'dbaas', + severity: 0, + tags: [''], + trigger_conditions: triggerConditionFactory.build(), + updated: new Date().toISOString(), +}); + +/** + * Fills metric details in the form. + * @param ruleIndex - The index of the rule to fill. + * @param dataField - The metric's data field (e.g., "CPU Utilization"). + * @param aggregationType - The aggregation type (e.g., "Average"). + * @param operator - The operator (e.g., ">=", "=="). + * @param threshold - The threshold value for the metric. + */ +const fillMetricDetailsForSpecificRule = ({ + aggregationType, + dataField, + operator, + ruleIndex, + threshold, +}: MetricDetails) => { + cy.get(`[data-testid="rule_criteria.rules.${ruleIndex}-id"]`).within(() => { + // Fill Data Field + ui.autocomplete + .findByLabel('Data Field') + .should('be.visible') + .type(dataField); + + ui.autocompletePopper.findByTitle(dataField).should('be.visible').click(); + + // Validate Aggregation Type + ui.autocomplete + .findByLabel('Aggregation Type') + .should('be.visible') + .type(aggregationType); + + ui.autocompletePopper + .findByTitle(aggregationType) + .should('be.visible') + .click(); + + // Fill Operator + ui.autocomplete.findByLabel('Operator').should('be.visible').type(operator); + + ui.autocompletePopper.findByTitle(operator).should('be.visible').click(); + + // Fill Threshold + cy.get('[data-qa-threshold]').should('be.visible').clear(); + cy.get('[data-qa-threshold]').should('be.visible').type(threshold); + }); +}; + +describe('Create Alert', () => { + /* + * - Confirms that users can navigate from the Alert Listings page to the Create Alert page. + * - Confirms that users can enter alert details, select resources, and configure conditions. + * - Confirms that the UI allows adding notification channels and setting thresholds. + * - Confirms client-side validation when entering invalid metric values. + * - Confirms that API interactions work correctly and return the expected responses. + * - Confirms that the UI displays a success message after creating an alert. + */ + beforeEach(() => { + mockAppendFeatureFlags(flags); + mockGetAccount(mockAccount); + mockGetCloudPulseServices([serviceType]); + mockGetRegions([mockRegion]); + mockGetCloudPulseMetricDefinitions(serviceType, metricDefinitions); + mockGetDatabases(databaseMock); + mockGetAllAlertDefinitions([mockAlerts]).as('getAlertDefinitionsList'); + mockGetAlertChannels([notificationChannels]); + mockCreateAlertDefinition(serviceType, customAlertDefinition).as( + 'createAlertDefinition' + ); + }); + + it('should navigate to the Create Alert page from the Alert Listings page', () => { + // Navigate to the alert definitions list page with login + cy.visitWithLogin('/alerts/definitions'); + + // Wait for the alert definitions list API call to complete + cy.wait('@getAlertDefinitionsList'); + + ui.buttonGroup + .findButtonByTitle('Create Alert') + .should('be.visible') + .should('be.enabled') + .click(); + + // Verify the URL ends with the expected details page path + cy.url().should('endWith', '/alerts/definitions/create'); + }); + + it('should successfully create a new alert', () => { + cy.visitWithLogin('/alerts/definitions/create'); + + // Enter Name and Description + cy.findByPlaceholderText('Enter a Name') + .should('be.visible') + .type(customAlertDefinition.label); + + cy.findByPlaceholderText('Enter a Description') + .should('be.visible') + .type(customAlertDefinition.description ?? ''); + + // Select Service + ui.autocomplete + .findByLabel('Service') + .should('be.visible') + .type('Databases'); + ui.autocompletePopper.findByTitle('Databases').should('be.visible').click(); + // Select Severity + ui.autocomplete.findByLabel('Severity').should('be.visible').type('Severe'); + ui.autocompletePopper.findByTitle('Severe').should('be.visible').click(); + + // Search for Resource + cy.findByPlaceholderText('Search for a Region or Resource') + .should('be.visible') + .type('database-2'); + + // Find the table and locate the resource cell containing 'database-2', then check the corresponding checkbox + cy.get('[data-qa-alert-table="true"]') // Find the table + .contains('[data-qa-alert-cell*="resource"]', 'database-2') // Find resource cell + .parents('tr') + .find('[type="checkbox"]') + .check(); + + // Assert resource selection notice + cy.findByText('1 of 10 resources are selected.'); + + // Fill metric details for the first rule + const cpuUsageMetricDetails = { + aggregationType: 'Average', + dataField: 'CPU Utilization', + operator: '=', + ruleIndex: 0, + threshold: '1000', + }; + + fillMetricDetailsForSpecificRule(cpuUsageMetricDetails); + + // Add metrics + cy.findByRole('button', { name: 'Add metric' }) + .should('be.visible') + .click(); + + ui.buttonGroup + .findButtonByTitle('Add dimension filter') + .should('be.visible') + .click(); + + ui.autocomplete + .findByLabel('Data Field') + .eq(1) + .should('be.visible') + .clear(); + + ui.autocomplete + .findByLabel('Data Field') + .eq(1) + .should('be.visible') + .type('State of CPU'); + + cy.findByText('State of CPU').should('be.visible').click(); + + ui.autocomplete.findByLabel('Operator').eq(1).should('be.visible').clear(); + + ui.autocomplete.findByLabel('Operator').eq(1).type('Equal'); + + cy.findByText('Equal').should('be.visible').click(); + + ui.autocomplete.findByLabel('Value').should('be.visible').type('User'); + + cy.findByText('User').should('be.visible').click(); + + // Fill metric details for the second rule + + const memoryUsageMetricDetails = { + aggregationType: 'Average', + dataField: 'Memory Usage', + operator: '=', + ruleIndex: 1, + threshold: '1000', + }; + + fillMetricDetailsForSpecificRule(memoryUsageMetricDetails); + // Set evaluation period + ui.autocomplete + .findByLabel('Evaluation Period') + .should('be.visible') + .type('5 min'); + ui.autocompletePopper.findByTitle('5 min').should('be.visible').click(); + + // Set polling interval + ui.autocomplete + .findByLabel('Polling Interval') + .should('be.visible') + .type('5 min'); + ui.autocompletePopper.findByTitle('5 min').should('be.visible').click(); + + // Set trigger occurrences + cy.get('[data-qa-trigger-occurrences]').should('be.visible').clear(); + + cy.get('[data-qa-trigger-occurrences]').should('be.visible').type('5'); + + // Add notification channel + ui.buttonGroup.find().contains('Add notification channel').click(); + + ui.autocomplete.findByLabel('Type').should('be.visible').type('Email'); + ui.autocompletePopper.findByTitle('Email').should('be.visible').click(); + + ui.autocomplete + .findByLabel('Channel') + .should('be.visible') + .type('channel-1'); + + ui.autocompletePopper.findByTitle('channel-1').should('be.visible').click(); + + // Add channel + ui.drawer + .findByTitle('Add Notification Channel') + .should('be.visible') + .within(() => { + ui.buttonGroup + .findButtonByTitle('Add channel') + .should('be.visible') + .click(); + }); + // Click on submit button + ui.buttonGroup + .find() + .find('button') + .filter('[type="submit"]') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@createAlertDefinition').then(({ request }) => { + const { + description, + label, + rule_criteria: { rules }, + severity, + trigger_conditions: { + criteria_condition, + evaluation_period_seconds, + polling_interval_seconds, + trigger_occurrences, + }, + } = customAlertDefinition; + + const { created_by, status, updated } = mockAlerts; + + // Validate top-level properties + expect(request.body.label).to.equal(label); + expect(request.body.description).to.equal(description); + expect(request.body.severity).to.equal(severity); + + // Validate rule criteria + expect(request.body.rule_criteria).to.have.property('rules'); + expect(request.body.rule_criteria.rules) + .to.be.an('array') + .with.length(rules.length); + + // Validate first rule + const firstRule = request.body.rule_criteria.rules[0]; + const firstCustomRule = rules[0]; + expect(firstRule.aggregate_function).to.equal( + firstCustomRule.aggregate_function + ); + expect(firstRule.metric).to.equal(firstCustomRule.metric); + expect(firstRule.operator).to.equal(firstCustomRule.operator); + expect(firstRule.threshold).to.equal(firstCustomRule.threshold); + expect(firstRule.dimension_filters[0]?.dimension_label ?? '').to.equal( + firstCustomRule.dimension_filters?.[0]?.dimension_label ?? '' + ); + expect(firstRule.dimension_filters[0]?.operator ?? '').to.equal( + firstCustomRule.dimension_filters?.[0]?.operator ?? '' + ); + expect(firstRule.dimension_filters[0]?.value ?? '').to.equal( + firstCustomRule.dimension_filters?.[0]?.value ?? '' + ); + + // Validate second rule + const secondRule = request.body.rule_criteria.rules[1]; + const secondCustomRule = rules[1]; + expect(secondRule.aggregate_function).to.equal( + secondCustomRule.aggregate_function + ); + expect(secondRule.metric).to.equal(secondCustomRule.metric); + expect(secondRule.operator).to.equal(secondCustomRule.operator); + expect(secondRule.threshold).to.equal(secondCustomRule.threshold); + + // Validate trigger conditions + const triggerConditions = request.body.trigger_conditions; + expect(triggerConditions.trigger_occurrences).to.equal( + trigger_occurrences + ); + expect(triggerConditions.evaluation_period_seconds).to.equal( + evaluation_period_seconds + ); + expect(triggerConditions.polling_interval_seconds).to.equal( + polling_interval_seconds + ); + expect(triggerConditions.criteria_condition).to.equal(criteria_condition); + + // Validate entity IDs and channels + expect(request.body.entity_ids).to.include('2'); + expect(request.body.channel_ids).to.include(1); + + // Verify URL redirection and toast notification + cy.url().should('endWith', '/alerts/definitions'); + ui.toast.assertMessage('Alert successfully created'); + + // Confirm that Alert is listed on landing page with expected configuration. + cy.findByText(label) + .closest('tr') + .within(() => { + cy.findByText(label).should('be.visible'); + cy.findByText(statusMap[status]).should('be.visible'); + cy.findByText('Databases').should('be.visible'); + cy.findByText(created_by).should('be.visible'); + cy.findByText( + formatDate(updated, { format: 'MMM dd, yyyy, h:mm a' }) + ); + }); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts index 63d47b8b555..6b88891e6c4 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/dbaas-widgets-verification.spec.ts @@ -1,17 +1,24 @@ /** * @file Integration Tests for CloudPulse Dbass Dashboard. */ -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { widgetDetails } from 'support/constants/widgets'; +import { mockGetAccount } from 'support/intercepts/account'; import { mockCreateCloudPulseJWEToken, - mockGetCloudPulseDashboard, mockCreateCloudPulseMetrics, + mockGetCloudPulseDashboard, mockGetCloudPulseDashboards, mockGetCloudPulseMetricDefinitions, mockGetCloudPulseServices, } from 'support/intercepts/cloudpulse'; +import { mockGetDatabases } from 'support/intercepts/databases'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { mockGetUserPreferences } from 'support/intercepts/profile'; +import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; -import { widgetDetails } from 'support/constants/widgets'; +import { generateRandomMetricsData } from 'support/util/cloudpulse'; + import { accountFactory, cloudPulseMetricsResponseFactory, @@ -23,16 +30,11 @@ import { regionFactory, widgetFactory, } from 'src/factories'; -import { mockGetAccount } from 'support/intercepts/account'; -import { mockGetLinodes } from 'support/intercepts/linodes'; -import { mockGetUserPreferences } from 'support/intercepts/profile'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { CloudPulseMetricsResponse, Database } from '@linode/api-v4'; -import { generateRandomMetricsData } from 'support/util/cloudpulse'; -import { mockGetDatabases } from 'support/intercepts/databases'; import { generateGraphData } from 'src/features/CloudPulse/Utils/CloudPulseWidgetUtils'; -import type { Flags } from 'src/featureFlags'; import { formatToolTip } from 'src/features/CloudPulse/Utils/unitConversion'; + +import type { CloudPulseMetricsResponse, Database } from '@linode/api-v4'; +import type { Flags } from 'src/featureFlags'; import type { Interception } from 'support/cypress-exports'; /** @@ -49,7 +51,7 @@ const expectedGranularityArray = ['Auto', '1 day', '1 hr', '5 min']; const timeDurationToSelect = 'Last 24 Hours'; const flags: Partial = { - aclp: { enabled: true, beta: true }, + aclp: { beta: true, enabled: true }, aclpResourceTypeMap: [ { dimensionKey: 'LINODE_ID', @@ -67,29 +69,29 @@ const flags: Partial = { }; const { - metrics, - id, - serviceType, + clusterName, dashboardName, engine, - clusterName, + id, + metrics, nodeType, + serviceType, } = widgetDetails.dbaas; const dashboard = dashboardFactory.build({ label: dashboardName, service_type: serviceType, - widgets: metrics.map(({ title, yLabel, name, unit }) => { + widgets: metrics.map(({ name, title, unit, yLabel }) => { return widgetFactory.build({ label: title, - y_label: yLabel, metric: name, unit, + y_label: yLabel, }); }), }); -const metricDefinitions = metrics.map(({ title, name, unit }) => +const metricDefinitions = metrics.map(({ name, title, unit }) => dashboardMetricFactory.build({ label: title, metric: name, @@ -98,8 +100,8 @@ const metricDefinitions = metrics.map(({ title, name, unit }) => ); const mockLinode = linodeFactory.build({ - label: clusterName, id: kubeLinodeFactory.build().instance_id ?? undefined, + label: clusterName, }); const mockAccount = accountFactory.build(); @@ -141,7 +143,7 @@ const getWidgetLegendRowValuesFromResponse = ( // Generate graph data using the provided parameters const graphData = generateGraphData({ flags, - label: label, + label, metricsList: responsePayload, resources: [ { @@ -150,9 +152,9 @@ const getWidgetLegendRowValuesFromResponse = ( region: 'us-ord', }, ], - serviceType: serviceType, + serviceType, status: 'success', - unit: unit, + unit, }); // Destructure metrics data from the first legend row @@ -167,17 +169,17 @@ const getWidgetLegendRowValuesFromResponse = ( }; const databaseMock: Database = databaseFactory.build({ - label: clusterName, - type: engine, - region: mockRegion.label, - version: '1', - status: 'provisioning', cluster_size: 2, engine: 'mysql', hosts: { primary: undefined, secondary: undefined, }, + label: clusterName, + region: mockRegion.label, + status: 'provisioning', + type: engine, + version: '1', }); describe('Integration Tests for DBaaS Dashboard ', () => { @@ -197,8 +199,8 @@ describe('Integration Tests for DBaaS Dashboard ', () => { mockGetUserPreferences({}); mockGetDatabases([databaseMock]).as('getDatabases'); - // navigate to the cloudpulse page - cy.visitWithLogin('monitor'); + // navigate to the metrics page + cy.visitWithLogin('/metrics'); // Wait for the services and dashboard API calls to complete before proceeding cy.wait(['@fetchServices', '@fetchDashboard']); @@ -225,7 +227,7 @@ describe('Integration Tests for DBaaS Dashboard ', () => { .should('be.visible') .click(); - //Select a Database Engine from the autocomplete input. + // Select a Database Engine from the autocomplete input. ui.autocomplete .findByLabel('Database Engine') .should('be.visible') @@ -246,7 +248,8 @@ describe('Integration Tests for DBaaS Dashboard ', () => { ).should('not.exist'); }); - ui.regionSelect.find().click().clear(); + ui.regionSelect.find().click(); + ui.regionSelect.find().clear(); ui.regionSelect .findItemByRegionId(mockRegion.id, [mockRegion]) .should('be.visible') @@ -301,13 +304,13 @@ describe('Integration Tests for DBaaS Dashboard ', () => { metricsAPIResponsePayload ).as('getGranularityMetrics'); - //find the interval component and select the expected granularity + // find the interval component and select the expected granularity ui.autocomplete .findByLabel('Select an Interval') .should('be.visible') - .type(`${testData.expectedGranularity}{enter}`); //type expected granularity + .type(`${testData.expectedGranularity}{enter}`); // type expected granularity - //check if the API call is made correctly with time granularity value selected + // check if the API call is made correctly with time granularity value selected cy.wait('@getGranularityMetrics').then((interception) => { expect(interception) .to.have.property('response') @@ -317,7 +320,7 @@ describe('Integration Tests for DBaaS Dashboard ', () => { ); }); - //validate the widget areachart is present + // validate the widget areachart is present cy.get('.recharts-responsive-container').within(() => { const expectedWidgetValues = getWidgetLegendRowValuesFromResponse( metricsAPIResponsePayload, @@ -355,13 +358,13 @@ describe('Integration Tests for DBaaS Dashboard ', () => { metricsAPIResponsePayload ).as('getAggregationMetrics'); - //find the interval component and select the expected granularity + // find the interval component and select the expected granularity ui.autocomplete .findByLabel('Select an Aggregate Function') .should('be.visible') - .type(`${testData.expectedAggregation}{enter}`); //type expected granularity + .type(`${testData.expectedAggregation}{enter}`); // type expected granularity - //check if the API call is made correctly with time granularity value selected + // check if the API call is made correctly with time granularity value selected cy.wait('@getAggregationMetrics').then((interception) => { expect(interception) .to.have.property('response') @@ -371,7 +374,7 @@ describe('Integration Tests for DBaaS Dashboard ', () => { ); }); - //validate the widget areachart is present + // validate the widget areachart is present cy.get('.recharts-responsive-container').within(() => { const expectedWidgetValues = getWidgetLegendRowValuesFromResponse( metricsAPIResponsePayload, diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts index a67d1fbdf98..b0f28d636c0 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-system-alert.spec.ts @@ -4,38 +4,41 @@ * This file contains Cypress tests for the Edit Alert page of the CloudPulse application. * It ensures that users can navigate to the Edit Alert Page and that alerts are correctly displayed and interactive on the Edit page. */ -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { - accountFactory, - alertFactory, - databaseFactory, - regionFactory, -} from 'src/factories'; import { mockGetAccount } from 'support/intercepts/account'; -import type { Flags } from 'src/featureFlags'; import { mockGetAlertDefinitions, mockGetAllAlertDefinitions, mockUpdateAlertDefinitions, } from 'support/intercepts/cloudpulse'; +import { mockGetDatabases } from 'support/intercepts/databases'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; -import { Alert, Database } from '@linode/api-v4'; -import { mockGetDatabases } from 'support/intercepts/databases'; -const flags: Partial = { aclp: { enabled: true, beta: true } }; +import { + accountFactory, + alertFactory, + databaseFactory, + regionFactory, +} from 'src/factories'; + +import type { Alert, Database } from '@linode/api-v4'; +import type { Flags } from 'src/featureFlags'; + +const flags: Partial = { aclp: { beta: true, enabled: true } }; const expectedResourceIds = Array.from({ length: 50 }, (_, i) => String(i + 1)); const mockAccount = accountFactory.build(); const alertDetails = alertFactory.build({ - label: 'Alert-1', description: 'Test description', + entity_ids: ['1', '2', '3'], + label: 'Alert-1', service_type: 'dbaas', severity: 1, status: 'enabled', type: 'system', }); -const { service_type, id, label } = alertDetails; +const { id, label, service_type } = alertDetails; const regions = [ regionFactory.build({ capabilities: ['Managed Databases'], @@ -52,23 +55,23 @@ const databases: Database[] = databaseFactory .buildList(50) .map((db, index) => ({ ...db, - type: 'MySQL', + engine: 'mysql', region: regions[index % regions.length].id, status: 'active', - engine: 'mysql', + type: 'MySQL', })); const pages = [1, 2]; describe('Integration Tests for Edit Alert', () => { /* - * - Confirms navigation from the Alert Definitions List page to the Edit Alert page. - * - Confirms alert creation is successful using mock API data. - * - Confirms that UI handles API interactions and displays correct data. - * - Confirms that UI redirects back to the Alert Definitions List page after saving updates. - * - Confirms that a toast notification appears upon successful alert update. - * - Confirms that UI redirects to the alert listing page after creating an alert. - * - Confirms that after submitting, the data matches with the API response. - */ + * - Confirms navigation from the Alert Definitions List page to the Edit Alert page. + * - Confirms alert creation is successful using mock API data. + * - Confirms that UI handles API interactions and displays correct data. + * - Confirms that UI redirects back to the Alert Definitions List page after saving updates. + * - Confirms that a toast notification appears upon successful alert update. + * - Confirms that UI redirects to the alert listing page after creating an alert. + * - Confirms that after submitting, the data matches with the API response. + */ beforeEach(() => { mockAppendFeatureFlags(flags); mockGetAccount(mockAccount); @@ -85,7 +88,7 @@ describe('Integration Tests for Edit Alert', () => { it('should navigate from the Alert Definitions List page to the Edit Alert page', () => { // Navigate to the alert definitions list page with login - cy.visitWithLogin('/monitor/alerts/definitions'); + cy.visitWithLogin('/alerts/definitions'); // Wait for the alert definitions list API call to complete cy.wait('@getAlertDefinitionsList'); @@ -110,7 +113,7 @@ describe('Integration Tests for Edit Alert', () => { it('should correctly display and update the details of the alert in the edit alert page', () => { // Navigate to the Edit Alert page - cy.visitWithLogin(`/monitor/alerts/definitions/edit/${service_type}/${id}`); + cy.visitWithLogin(`/alerts/definitions/edit/${service_type}/${id}`); cy.wait(['@getAlertDefinitions', '@getDatabases']); @@ -120,29 +123,21 @@ describe('Integration Tests for Edit Alert', () => { // Verify that the heading with text 'region' is visible ui.heading.findByText('region').should('be.visible'); - // Verify the initial selection of resources - cy.get('[data-qa-notice="true"]').should( - 'contain.text', - '3 of 50 resources are selected' - ); - // Select all resources - cy.get('[data-qa-notice="true"]').within(() => { - ui.button - .findByTitle('Select All') - .should('be.visible') - .click(); - - // Unselect button should be visible after clicking on Select All button - ui.button - .findByTitle('Unselect All') - .should('be.visible') - .should('be.enabled'); - }); + // Verify the initial selection of resources, then select all resources. + cy.findByText('3 of 50 resources are selected.') + .should('be.visible') + .closest('[data-qa-notice]') + .within(() => { + ui.button.findByTitle('Select All').should('be.visible').click(); - cy.get('[data-qa-notice="true"]').should( - 'contain.text', - '50 of 50 resources are selected' - ); + ui.button + .findByTitle('Deselect All') + .should('be.visible') + .should('be.enabled'); + }); + + // Confirm notice text updates to reflect selection. + cy.findByText('50 of 50 resources are selected.').should('be.visible'); // Verify the initial state of the page size ui.pagination.findPageSizeSelect().click(); @@ -200,11 +195,11 @@ describe('Integration Tests for Edit Alert', () => { cy.wait('@updateDefinitions').then(({ request, response }) => { const { - type, - status, - severity, - description, created_by, + description, + severity, + status, + type, updated_by, } = alertDetails; @@ -221,8 +216,8 @@ describe('Integration Tests for Edit Alert', () => { // Destructure alert_channels and trigger_conditions from alertResponse const { alert_channels, - trigger_conditions: responseTriggerConditions, tags, + trigger_conditions: responseTriggerConditions, } = alertResponse; const { criteria_condition: responseCriteriaCondition, @@ -277,7 +272,7 @@ describe('Integration Tests for Edit Alert', () => { expect(tags).to.include('tag2'); // Validate navigation - cy.url().should('endWith', '/monitor/alerts/definitions'); + cy.url().should('endWith', '/alerts/definitions'); // Confirm toast notification appears ui.toast.assertMessage('Alert resources successfully updated.'); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts new file mode 100644 index 00000000000..a39d248f5c6 --- /dev/null +++ b/packages/manager/cypress/e2e/core/cloudpulse/edit-user-alert.spec.ts @@ -0,0 +1,381 @@ +/** + * @file Integration Tests for the CloudPulse Edit Alert Page. + * + * This file contains Cypress tests for the Edit Alert page of the CloudPulse application. + * It verifies that alert details are correctly displayed, interactive, and editable. + */ + +import { + EVALUATION_PERIOD_DESCRIPTION, + METRIC_DESCRIPTION_DATA_FIELD, + POLLING_INTERVAL_DESCRIPTION, + SEVERITY_LEVEL_DESCRIPTION, +} from 'support/constants/cloudpulse'; +import { widgetDetails } from 'support/constants/widgets'; +import { mockGetAccount } from 'support/intercepts/account'; +import { + mockCreateAlertDefinition, + mockGetAlertChannels, + mockGetAlertDefinitions, + mockGetAllAlertDefinitions, + mockGetCloudPulseMetricDefinitions, + mockGetCloudPulseServices, + mockUpdateAlertDefinitions, +} from 'support/intercepts/cloudpulse'; +import { mockGetDatabases } from 'support/intercepts/databases'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; + +import { + accountFactory, + alertDefinitionFactory, + alertFactory, + cpuRulesFactory, + dashboardMetricFactory, + databaseFactory, + memoryRulesFactory, + notificationChannelFactory, + regionFactory, + triggerConditionFactory, +} from 'src/factories'; +import { formatDate } from 'src/utilities/formatDate'; + +import type { Database } from '@linode/api-v4'; +import type { Flags } from 'src/featureFlags'; + +// Feature flag setup +const flags: Partial = { aclp: { beta: true, enabled: true } }; +const mockAccount = accountFactory.build(); + +// Mock alert definition +const customAlertDefinition = alertDefinitionFactory.build({ + channel_ids: [1], + description: 'update-description', + entity_ids: ['1', '2', '3', '4', '5'], + label: 'Alert-1', + rule_criteria: { + rules: [cpuRulesFactory.build(), memoryRulesFactory.build()], + }, + severity: 0, + tags: [''], + trigger_conditions: triggerConditionFactory.build(), +}); + +// Mock alert details +const alertDetails = alertFactory.build({ + alert_channels: [{ id: 1 }], + created_by: 'user1', + description: 'My Custom Description', + entity_ids: ['2'], + label: 'Alert-2', + rule_criteria: { + rules: [cpuRulesFactory.build(), memoryRulesFactory.build()], + }, + service_type: 'dbaas', + severity: 0, + tags: [''], + trigger_conditions: triggerConditionFactory.build(), + type: 'user', + updated: new Date().toISOString(), +}); + +const { description, id, label, service_type, updated } = alertDetails; + +// Mock regions +const regions = [ + regionFactory.build({ + capabilities: ['Managed Databases'], + id: 'us-ord', + label: 'Chicago, IL', + }), + regionFactory.build({ + capabilities: ['Managed Databases'], + id: 'us-east', + label: 'Newark', + }), +]; + +// Mock databases +const databases: Database[] = databaseFactory.buildList(5).map((db, index) => ({ + ...db, + engine: 'mysql', + id: index, + region: regions[index % regions.length].id, + status: 'active', + type: 'MySQL', +})); + +// Mock metric definitions +const { metrics } = widgetDetails.dbaas; +const metricDefinitions = metrics.map(({ name, title, unit }) => + dashboardMetricFactory.build({ label: title, metric: name, unit }) +); + +// Mock notification channels +const notificationChannels = notificationChannelFactory.build({ + channel_type: 'email', + id: 1, + label: 'Channel-1', + type: 'custom', +}); + +describe('Integration Tests for Edit Alert', () => { + /* + * - Confirms that the Edit Alert page loads with the correct alert details. + * - Verifies that the alert form contains the appropriate pre-filled data from the mock alert. + * - Confirms that rule criteria values are correctly displayed. + * - Verifies that the correct notification channel details are displayed. + * - Ensures the tooltip descriptions for the alert configuration are visible and contain the correct content. + * - Confirms that the correct regions, databases, and metrics are available for selection in the form. + * - Verifies that the user can successfully edit and submit changes to the alert. + * - Confirms that the UI handles updates to alert data correctly and submits them via the API. + * - Confirms that the API request matches the expected data structure and values upon saving the updated alert. + * - Verifies that the user is redirected back to the Alert Definitions List page after saving changes. + * - Ensures a success toast notification appears after the alert is updated. + * - Confirms that the alert is listed correctly with the updated configuration on the Alert Definitions List page. + */ + beforeEach(() => { + // Mocking various API responses + mockAppendFeatureFlags(flags); + mockGetAccount(mockAccount); + mockGetRegions(regions); + mockGetCloudPulseServices([alertDetails.service_type]); + mockGetAllAlertDefinitions([alertDetails]).as('getAlertDefinitionsList'); + mockGetAlertDefinitions(service_type, id, alertDetails).as( + 'getAlertDefinitions' + ); + mockGetDatabases(databases).as('getDatabases'); + mockUpdateAlertDefinitions(service_type, id, alertDetails).as( + 'updateDefinitions' + ); + mockCreateAlertDefinition(service_type, customAlertDefinition).as( + 'createAlertDefinition' + ); + mockGetCloudPulseMetricDefinitions(service_type, metricDefinitions); + mockGetAlertChannels([notificationChannels]); + }); + + // Define an interface for rule values + interface RuleCriteria { + aggregationType: string; + dataField: string; + operator: string; + threshold: string; + } + + // Mapping of interface keys to data attributes + const fieldSelectors: Record = { + aggregationType: 'aggregation-type', + dataField: 'data-field', + operator: 'operator', + threshold: 'threshold', + }; + + // Function to assert rule values + const assertRuleValues = (ruleIndex: number, rule: RuleCriteria) => { + cy.get(`[data-testid="rule_criteria.rules.${ruleIndex}-id"]`).within(() => { + (Object.keys(rule) as (keyof RuleCriteria)[]).forEach((key) => { + cy.get( + `[data-qa-metric-threshold="rule_criteria.rules.${ruleIndex}-${fieldSelectors[key]}"]` + ) + .should('be.visible') + .find('input') + .should('have.value', rule[key]); + }); + }); + }; + + it('should correctly display the details of the alert in the Edit Alert page', () => { + cy.visitWithLogin(`/alerts/definitions/edit/${service_type}/${id}`); + cy.wait('@getAlertDefinitions'); + + // Verify form fields + cy.findByLabelText('Name').should('have.value', label); + cy.findByLabelText('Description (optional)').should( + 'have.value', + description + ); + cy.findByLabelText('Service') + .should('be.disabled') + .should('have.value', 'Databases'); + cy.findByLabelText('Severity').should('have.value', 'Severe'); + + // Verify alert resource selection + cy.get('[data-qa-alert-table="true"]') + .contains('[data-qa-alert-cell*="resource"]', 'database-3') + .parents('tr') + .find('[type="checkbox"]') + .should('be.checked'); + + // Verify alert resource selection count message + cy.get('[data-testid="selection_notice"]').should( + 'contain', + '1 of 5 resources are selected.' + ); + + // Assert rule values 1 + assertRuleValues(0, { + aggregationType: 'Average', + dataField: 'CPU Utilization', + operator: '=', + threshold: '1000', + }); + + // Assert rule values 2 + assertRuleValues(1, { + aggregationType: 'Average', + dataField: 'Memory Usage', + operator: '=', + threshold: '1000', + }); + + // Verify that tooltip messages are displayed correctly with accurate content. + ui.tooltip.findByText(METRIC_DESCRIPTION_DATA_FIELD).should('be.visible'); + ui.tooltip.findByText(SEVERITY_LEVEL_DESCRIPTION).should('be.visible'); + ui.tooltip.findByText(EVALUATION_PERIOD_DESCRIPTION).should('be.visible'); + ui.tooltip.findByText(POLLING_INTERVAL_DESCRIPTION).should('be.visible'); + + // Assert dimension filters + const dimensionFilters = [ + { field: 'State of CPU', operator: 'Equal', value: 'User' }, + ]; + + dimensionFilters.forEach((filter, index) => { + cy.get( + `[data-qa-dimension-filter="rule_criteria.rules.0.dimension_filters.${index}-data-field"]` + ) + .should('be.visible') + .find('input') + .should('have.value', filter.field); + + cy.get( + `[data-qa-dimension-filter="rule_criteria.rules.0.dimension_filters.${index}-operator"]` + ) + .should('be.visible') + .find('input') + .should('have.value', filter.operator); + + cy.get( + `[data-qa-dimension-filter="rule_criteria.rules.0.dimension_filters.${index}-value"]` + ) + .should('be.visible') + .find('input') + .should('have.value', filter.value); + }); + + // Verify notification details + cy.get('[data-qa-notification="notification-channel-0"]').within(() => { + cy.get('[data-qa-channel]').should('have.text', 'Channel-1'); + cy.get('[data-qa-type]').next().should('have.text', 'Email'); + cy.get('[data-qa-channel-details]').should( + 'have.text', + 'test@test.comtest2@test.com' + ); + }); + }); + + it('successfully updated alert details and verified that the API request matches the expected test data.', () => { + cy.visitWithLogin(`/alerts/definitions/edit/${service_type}/${id}`); + cy.wait('@getAlertDefinitions'); + + // Make changes to alert form + cy.findByLabelText('Name').clear(); + cy.findByLabelText('Name').type('Alert-2'); + cy.findByLabelText('Description (optional)').clear(); + cy.findByLabelText('Description (optional)').type('update-description'); + cy.findByLabelText('Service').should('be.disabled'); + ui.autocomplete.findByLabel('Severity').clear(); + ui.autocomplete.findByLabel('Severity').type('Info'); + ui.autocompletePopper.findByTitle('Info').should('be.visible').click(); + cy.get('[data-qa-notice="true"]') + .find('button') + .contains('Select All') + .click(); + + cy.get( + '[data-qa-metric-threshold="rule_criteria.rules.0-data-field"]' + ).within(() => { + ui.button.findByAttribute('aria-label', 'Clear').click(); + }); + + cy.get('[data-testid="rule_criteria.rules.0-id"]').within(() => { + ui.autocomplete.findByLabel('Data Field').type('Disk I/O'); + ui.autocompletePopper.findByTitle('Disk I/O').click(); + ui.autocomplete.findByLabel('Aggregation Type').type('Minimum'); + ui.autocompletePopper.findByTitle('Minimum').click(); + ui.autocomplete.findByLabel('Operator').type('>'); + ui.autocompletePopper.findByTitle('>').click(); + cy.get('[data-qa-threshold]').should('be.visible').clear(); + cy.get('[data-qa-threshold]').should('be.visible').type('2000'); + }); + + // click on the submit button + ui.buttonGroup + .find() + .find('button') + .filter('[type="submit"]') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.wait('@updateDefinitions').then(({ request }) => { + // Assert the API request data + expect(request.body.label).to.equal('Alert-2'); + expect(request.body.description).to.equal('update-description'); + expect(request.body.severity).to.equal(3); + expect(request.body.entity_ids).to.have.members([ + '0', + '1', + '2', + '3', + '4', + ]); + expect(request.body.channel_ids[0]).to.equal(1); + expect(request.body).to.have.property('trigger_conditions'); + expect(request.body.trigger_conditions.criteria_condition).to.equal( + 'ALL' + ); + expect( + request.body.trigger_conditions.evaluation_period_seconds + ).to.equal(300); + expect(request.body.trigger_conditions.polling_interval_seconds).to.equal( + 300 + ); + expect(request.body.trigger_conditions.trigger_occurrences).to.equal(5); + expect(request.body.rule_criteria.rules[0].threshold).to.equal(2000); + expect(request.body.rule_criteria.rules[0].operator).to.equal('gt'); + expect(request.body.rule_criteria.rules[0].aggregate_function).to.equal( + 'min' + ); + expect(request.body.rule_criteria.rules[0].metric).to.equal( + 'system_disk_OPS_total' + ); + expect(request.body.rule_criteria.rules[1].aggregate_function).to.equal( + 'avg' + ); + expect(request.body.rule_criteria.rules[1].metric).to.equal( + 'system_memory_usage_by_resource' + ); + expect(request.body.rule_criteria.rules[1].operator).to.equal('eq'); + expect(request.body.rule_criteria.rules[1].threshold).to.equal(1000); + + // Verify URL redirection and toast notification + cy.url().should('endWith', 'alerts/definitions'); + ui.toast.assertMessage('Alert successfully updated.'); + + // Confirm that Alert is listed on landing page with expected configuration. + cy.findByText('Alert-2') + .closest('tr') + .within(() => { + cy.findByText('Alert-2').should('be.visible'); + cy.findByText('Enabled').should('be.visible'); + cy.findByText('Databases').should('be.visible'); + cy.findByText('user1').should('be.visible'); + cy.findByText( + formatDate(updated, { format: 'MMM dd, yyyy, h:mm a' }) + ).should('be.visible'); + }); + }); + }); +}); diff --git a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts index 647a8cc3c18..640d5b22b87 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/linode-widget-verification.spec.ts @@ -1,17 +1,23 @@ /** * @file Integration Tests for CloudPulse Linode Dashboard. */ -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { widgetDetails } from 'support/constants/widgets'; +import { mockGetAccount } from 'support/intercepts/account'; import { mockCreateCloudPulseJWEToken, - mockGetCloudPulseDashboard, mockCreateCloudPulseMetrics, + mockGetCloudPulseDashboard, mockGetCloudPulseDashboards, mockGetCloudPulseMetricDefinitions, mockGetCloudPulseServices, } from 'support/intercepts/cloudpulse'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { mockGetUserPreferences } from 'support/intercepts/profile'; +import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; -import { widgetDetails } from 'support/constants/widgets'; +import { generateRandomMetricsData } from 'support/util/cloudpulse'; + import { accountFactory, cloudPulseMetricsResponseFactory, @@ -22,15 +28,11 @@ import { regionFactory, widgetFactory, } from 'src/factories'; -import { mockGetAccount } from 'support/intercepts/account'; -import { mockGetLinodes } from 'support/intercepts/linodes'; -import { mockGetUserPreferences } from 'support/intercepts/profile'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { CloudPulseMetricsResponse } from '@linode/api-v4'; -import { generateRandomMetricsData } from 'support/util/cloudpulse'; import { generateGraphData } from 'src/features/CloudPulse/Utils/CloudPulseWidgetUtils'; -import { Flags } from 'src/featureFlags'; import { formatToolTip } from 'src/features/CloudPulse/Utils/unitConversion'; + +import type { CloudPulseMetricsResponse } from '@linode/api-v4'; +import type { Flags } from 'src/featureFlags'; import type { Interception } from 'support/cypress-exports'; /** @@ -46,7 +48,7 @@ import type { Interception } from 'support/cypress-exports'; const expectedGranularityArray = ['Auto', '1 day', '1 hr', '5 min']; const timeDurationToSelect = 'Last 24 Hours'; const flags: Partial = { - aclp: { enabled: true, beta: true }, + aclp: { beta: true, enabled: true }, aclpResourceTypeMap: [ { dimensionKey: 'LINODE_ID', @@ -63,28 +65,28 @@ const flags: Partial = { ], }; const { - metrics, - id, - serviceType, dashboardName, + id, + metrics, region, resource, + serviceType, } = widgetDetails.linode; const dashboard = dashboardFactory.build({ label: dashboardName, service_type: serviceType, - widgets: metrics.map(({ title, yLabel, name, unit }) => { + widgets: metrics.map(({ name, title, unit, yLabel }) => { return widgetFactory.build({ label: title, - y_label: yLabel, metric: name, unit, + y_label: yLabel, }); }), }); -const metricDefinitions = metrics.map(({ title, name, unit }) => +const metricDefinitions = metrics.map(({ name, title, unit }) => dashboardMetricFactory.build({ label: title, metric: name, @@ -93,8 +95,8 @@ const metricDefinitions = metrics.map(({ title, name, unit }) => ); const mockLinode = linodeFactory.build({ - label: resource, id: kubeLinodeFactory.build().instance_id ?? undefined, + label: resource, }); const mockAccount = accountFactory.build(); @@ -136,7 +138,7 @@ const getWidgetLegendRowValuesFromResponse = ( // Generate graph data using the provided parameters const graphData = generateGraphData({ flags, - label: label, + label, metricsList: responsePayload, resources: [ { @@ -145,9 +147,9 @@ const getWidgetLegendRowValuesFromResponse = ( region: 'us-ord', }, ], - serviceType: serviceType, + serviceType, status: 'success', - unit: unit, + unit, }); // Destructure metrics data from the first legend row @@ -177,8 +179,8 @@ describe('Integration Tests for Linode Dashboard ', () => { mockGetRegions([mockRegion]); mockGetUserPreferences({}); - // navigate to the cloudpulse page - cy.visitWithLogin('monitor'); + // navigate to the metrics page + cy.visitWithLogin('/metrics'); // Wait for the services and dashboard API calls to complete before proceeding cy.wait(['@fetchServices', '@fetchDashboard']); @@ -221,14 +223,16 @@ describe('Integration Tests for Linode Dashboard ', () => { }); // Select a region from the dropdown. - ui.regionSelect.find().click().clear().type(`${region}{enter}`); + ui.regionSelect.find().click(); + ui.regionSelect.find().clear(); + ui.regionSelect.find().type(`${region}{enter}`); // Select a resource from the autocomplete input. ui.autocomplete .findByLabel('Resources') .should('be.visible') - .type(`${resource}{enter}`) - .click(); + .type(`${resource}{enter}`); + ui.autocomplete.findByLabel('Resources').click(); cy.findByText(resource).should('be.visible'); @@ -262,13 +266,13 @@ describe('Integration Tests for Linode Dashboard ', () => { metricsAPIResponsePayload ).as('getGranularityMetrics'); - //find the interval component and select the expected granularity + // find the interval component and select the expected granularity ui.autocomplete .findByLabel('Select an Interval') .should('be.visible') - .type(`${testData.expectedGranularity}{enter}`); //type expected granularity + .type(`${testData.expectedGranularity}{enter}`); // type expected granularity - //check if the API call is made correctly with time granularity value selected + // check if the API call is made correctly with time granularity value selected cy.wait('@getGranularityMetrics').then((interception) => { expect(interception) .to.have.property('response') @@ -278,7 +282,7 @@ describe('Integration Tests for Linode Dashboard ', () => { ); }); - //validate the widget areachart is present + // validate the widget areachart is present cy.get('.recharts-responsive-container').within(() => { const expectedWidgetValues = getWidgetLegendRowValuesFromResponse( metricsAPIResponsePayload, @@ -319,13 +323,13 @@ describe('Integration Tests for Linode Dashboard ', () => { metricsAPIResponsePayload ).as('getAggregationMetrics'); - //find the interval component and select the expected granularity + // find the interval component and select the expected granularity ui.autocomplete .findByLabel('Select an Aggregate Function') .should('be.visible') - .type(`${testData.expectedAggregation}{enter}`); //type expected granularity + .type(`${testData.expectedAggregation}{enter}`); // type expected granularity - //check if the API call is made correctly with time granularity value selected + // check if the API call is made correctly with time granularity value selected cy.wait('@getAggregationMetrics').then((interception) => { expect(interception) .to.have.property('response') @@ -335,7 +339,7 @@ describe('Integration Tests for Linode Dashboard ', () => { ); }); - //validate the widget areachart is present + // validate the widget areachart is present cy.get('.recharts-responsive-container').within(() => { const expectedWidgetValues = getWidgetLegendRowValuesFromResponse( metricsAPIResponsePayload, diff --git a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts index 0b28827b7c7..8dab09b4141 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/timerange-verification.spec.ts @@ -1,17 +1,27 @@ /** * @file Integration Tests for CloudPulse Custom and Preset Verification */ -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { DateTime } from 'luxon'; +import { widgetDetails } from 'support/constants/widgets'; +import { mockGetAccount } from 'support/intercepts/account'; import { mockCreateCloudPulseJWEToken, - mockGetCloudPulseDashboard, mockCreateCloudPulseMetrics, + mockGetCloudPulseDashboard, mockGetCloudPulseDashboards, mockGetCloudPulseMetricDefinitions, mockGetCloudPulseServices, } from 'support/intercepts/cloudpulse'; +import { mockGetDatabases } from 'support/intercepts/databases'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetProfile, + mockGetUserPreferences, +} from 'support/intercepts/profile'; +import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; -import { widgetDetails } from 'support/constants/widgets'; +import { generateRandomMetricsData } from 'support/util/cloudpulse'; + import { accountFactory, cloudPulseMetricsResponseFactory, @@ -22,23 +32,14 @@ import { regionFactory, widgetFactory, } from 'src/factories'; -import { mockGetAccount } from 'support/intercepts/account'; -import { - mockGetProfile, - mockGetUserPreferences, -} from 'support/intercepts/profile'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { Database, DateTimeWithPreset } from '@linode/api-v4'; -import { generateRandomMetricsData } from 'support/util/cloudpulse'; -import { mockGetDatabases } from 'support/intercepts/databases'; -import type { Flags } from 'src/featureFlags'; -import type { Interception } from 'support/cypress-exports'; import { convertToGmt } from 'src/features/CloudPulse/Utils/CloudPulseDateTimePickerUtils'; import { formatDate } from 'src/utilities/formatDate'; -import { DateTime } from 'luxon'; + +import type { Database, DateTimeWithPreset } from '@linode/api-v4'; +import type { Flags } from 'src/featureFlags'; +import type { Interception } from 'support/cypress-exports'; const formatter = "yyyy-MM-dd'T'HH:mm:ss'Z'"; -const currentDate = new Date(); const cleanText = (string: string) => string.replace(/\u200e|\u2066|\u2067|\u2068|\u2069/g, ''); @@ -59,7 +60,7 @@ const mockRegion = regionFactory.build({ }); const flags: Partial = { - aclp: { enabled: true, beta: true }, + aclp: { beta: true, enabled: true }, aclpResourceTypeMap: [ { dimensionKey: 'cluster_id', @@ -70,23 +71,23 @@ const flags: Partial = { ], }; -const { metrics, id, serviceType, dashboardName, engine } = widgetDetails.dbaas; +const { dashboardName, engine, id, metrics, serviceType } = widgetDetails.dbaas; const dashboard = dashboardFactory.build({ label: dashboardName, service_type: serviceType, - widgets: metrics.map(({ title, yLabel, name, unit }) => { + widgets: metrics.map(({ name, title, unit, yLabel }) => { return widgetFactory.build({ label: title, - y_label: yLabel, metric: name, unit, + y_label: yLabel, }); }), }); const metricDefinitions = { - data: metrics.map(({ title, name, unit }) => + data: metrics.map(({ name, title, unit }) => dashboardMetricFactory.build({ label: title, metric: name, @@ -102,8 +103,8 @@ const metricsAPIResponsePayload = cloudPulseMetricsResponseFactory.build({ }); const databaseMock: Database = databaseFactory.build({ - type: engine, region: mockRegion.label, + type: engine, }); const mockProfile = profileFactory.build({ timezone: 'Etc/GMT', @@ -126,16 +127,14 @@ const mockProfile = profileFactory.build({ * - `month`: The month of the year as a number. */ const getDateRangeInGMT = ( - daysOffset: number, hour: number, - minute: number = 0 + minute: number = 0, + isStart: boolean = false ) => { const now = DateTime.now().setZone('GMT'); // Set the timezone to GMT - const targetDate = now - .startOf('month') - .plus({ days: daysOffset }) - .set({ hour, minute }); - + const targetDate = isStart + ? now.startOf('month').set({ hour, minute }) + : now.set({ hour, minute }); const actualDate = targetDate.toFormat('yyyy-LL-dd HH:mm'); // Format in GMT return { actualDate, @@ -235,13 +234,13 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura aclpPreference: { dashboardId: id, engine: engine.toLowerCase(), - resources: ['1'], region: mockRegion.id, + resources: ['1'], }, }).as('fetchPreferences'); mockGetDatabases([databaseMock]); - cy.visitWithLogin('monitor'); + cy.visitWithLogin('/metrics'); cy.wait(['@fetchServices', '@fetchDashboard', '@fetchPreferences']); }); @@ -252,13 +251,13 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura day: startDay, hour: startHour, minute: startMinute, - } = getDateRangeInGMT(0, 12, 15); + } = getDateRangeInGMT(12, 15, true); const { actualDate: endActualDate, day: endDay, hour: endHour, minute: endMinute, - } = getDateRangeInGMT(currentDate.getDate(), 12, 15); + } = getDateRangeInGMT(12, 30); // Select "Custom" from the "Time Range" dropdown ui.autocomplete @@ -284,24 +283,26 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura .click(); // Selects the start hour, minute, and meridiem (AM/PM) in the time picker. - cy.findByLabelText('Select hours') - .scrollIntoView({ easing: 'linear' }) - .within(() => { - cy.get(`[aria-label="${startHour} hours"]`).click(); - }); + .as('selectHours') + .scrollIntoView({ easing: 'linear' }); + cy.get('@selectHours').within(() => { + cy.get(`[aria-label="${startHour} hours"]`).click(); + }); cy.findByLabelText('Select minutes') - .scrollIntoView({ easing: 'linear', duration: 500 }) - .within(() => { - cy.get(`[aria-label="${startMinute} minutes"]`).click(); - }); + .as('selectMinutes') + .scrollIntoView({ duration: 500, easing: 'linear' }); + cy.get('@selectMinutes').within(() => { + cy.get(`[aria-label="${startMinute} minutes"]`).click(); + }); cy.findByLabelText('Select meridiem') - .scrollIntoView({ easing: 'linear', duration: 500 }) - .within(() => { - cy.get(`[aria-label="PM"]`).click(); - }); + .as('selectMeridiem') + .scrollIntoView({ duration: 500, easing: 'linear' }); + cy.get('@selectMeridiem').within(() => { + cy.get(`[aria-label="PM"]`).click(); + }); // Click the "Apply" button to confirm the start date and time ui.button @@ -312,7 +313,9 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura // Assert that the start date and time is correctly displayed cy.findByPlaceholderText('Select Start Date') - .scrollIntoView({ easing: 'linear' }) + .as('selectStartDate') + .scrollIntoView({ easing: 'linear' }); + cy.get('@selectStartDate') .should('be.visible') .should('have.value', `${cleanText(startActualDate)} PM`); @@ -329,23 +332,29 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura .click(); // Selects the start hour, minute, and meridiem (AM/PM) in the time picker. - cy.findByLabelText('Select hours') - .scrollIntoView({ easing: 'linear', duration: 500 }) - .within(() => { - cy.get(`[aria-label="${endHour} hours"]`).click(); - }); + cy.findByLabelText('Select hours').scrollIntoView({ + duration: 500, + easing: 'linear', + }); + cy.get('@selectHours').within(() => { + cy.get(`[aria-label="${endHour} hours"]`).click(); + }); - cy.findByLabelText('Select minutes') - .scrollIntoView({ easing: 'linear', duration: 500 }) - .within(() => { - cy.get(`[aria-label="${endMinute} minutes"]`).click(); - }); + cy.findByLabelText('Select minutes').scrollIntoView({ + duration: 500, + easing: 'linear', + }); + cy.get('@selectMinutes').within(() => { + cy.get(`[aria-label="${endMinute} minutes"]`).click(); + }); - cy.findByLabelText('Select meridiem') - .scrollIntoView({ easing: 'linear', duration: 500 }) - .within(() => { - cy.get(`[aria-label="PM"]`).click(); - }); + cy.findByLabelText('Select meridiem').scrollIntoView({ + duration: 500, + easing: 'linear', + }); + cy.get('@selectMeridiem').within(() => { + cy.get(`[aria-label="PM"]`).click(); + }); // Click the "Apply" button to confirm the end date and time ui.button @@ -355,8 +364,10 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura .click(); // Assert that the end date and time is correctly displayed + cy.findByPlaceholderText('Select End Date').scrollIntoView({ + easing: 'linear', + }); cy.findByPlaceholderText('Select End Date') - .scrollIntoView({ easing: 'linear' }) .should('be.visible') .should('have.value', `${cleanText(endActualDate)} PM`); @@ -380,7 +391,6 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura convertToGmt(endActualDate.replace(' ', 'T')) ); }); - // Click on the "Presets" button ui.buttonGroup.findButtonByTitle('Presets').should('be.visible').click(); @@ -455,7 +465,7 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura }); it('Select the "Last Month" preset from the "Time Range" dropdown and verify its functionality.', () => { - const { start, end } = getLastMonthRange(); + const { end, start } = getLastMonthRange(); ui.autocomplete .findByLabel('Time Range') @@ -486,7 +496,7 @@ describe('Integration tests for verifying Cloudpulse custom and preset configura }); it('Select the "This Month" preset from the "Time Range" dropdown and verify its functionality.', () => { - const { start, end } = getThisMonthRange(); + const { end, start } = getThisMonthRange(); ui.autocomplete .findByLabel('Time Range') diff --git a/packages/manager/cypress/e2e/core/databases/create-database.spec.ts b/packages/manager/cypress/e2e/core/databases/create-database.spec.ts index 251a08690bd..db6a3dee064 100644 --- a/packages/manager/cypress/e2e/core/databases/create-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/create-database.spec.ts @@ -1,21 +1,23 @@ -import { accountFactory, databaseFactory, eventFactory } from 'src/factories'; -import { mockGetAccount } from 'support/intercepts/account'; import { - databaseClusterConfiguration, databaseConfigurations, mockDatabaseEngineTypes, mockDatabaseNodeTypes, } from 'support/constants/databases'; +import { mockGetAccount } from 'support/intercepts/account'; import { mockCreateDatabase, - mockGetDatabases, mockGetDatabaseEngines, mockGetDatabaseTypes, + mockGetDatabases, } from 'support/intercepts/databases'; import { mockGetEvents } from 'support/intercepts/events'; -import { getRegionById } from 'support/util/regions'; import { ui } from 'support/ui'; +import { getRegionById } from 'support/util/regions'; + +import { accountFactory, databaseFactory, eventFactory } from 'src/factories'; + import type { Database } from '@linode/api-v4'; +import type { databaseClusterConfiguration } from 'support/constants/databases'; describe('create a database cluster, mocked data', () => { databaseConfigurations.forEach( @@ -24,17 +26,17 @@ describe('create a database cluster, mocked data', () => { it(`creates a ${configuration.linodeType} ${configuration.engine} v${configuration.version}.x ${configuration.clusterSize}-node cluster`, () => { // Database mock immediately after instance has been created. const databaseMock: Database = databaseFactory.build({ - label: configuration.label, - type: configuration.linodeType, - region: configuration.region.id, - version: configuration.version, - status: 'provisioning', cluster_size: configuration.clusterSize, engine: configuration.dbType, hosts: { primary: undefined, secondary: undefined, }, + label: configuration.label, + region: configuration.region.id, + status: 'provisioning', + type: configuration.linodeType, + version: configuration.version, }); // Database mock once instance has been provisioned. @@ -47,16 +49,16 @@ describe('create a database cluster, mocked data', () => { // Event mock which will trigger Cloud to re-fetch DBaaS instance. const eventMock = eventFactory.build({ - status: 'finished', action: 'database_create', - percent_complete: 100, entity: { - label: databaseMock.label, id: databaseMock.id, + label: databaseMock.label, type: 'database', url: `/v4/databases/${configuration.dbType}/instances/${databaseMock.id}`, }, + percent_complete: 100, secondary_entity: undefined, + status: 'finished', }); const clusterSizeSelection = @@ -86,17 +88,16 @@ describe('create a database cluster, mocked data', () => { cy.findByText('Create').should('be.visible'); }); - cy.findByText('Cluster Label') - .should('be.visible') - .click() - .type(configuration.label); + cy.findByText('Cluster Label').should('be.visible').click(); + cy.focused().type(configuration.label); - cy.findByText('Database Engine') - .should('be.visible') - .click() - .type(`${configuration.engine} v${configuration.version}{enter}`); + cy.findByText('Database Engine').should('be.visible').click(); + cy.focused().type( + `${configuration.engine} v${configuration.version}{enter}` + ); - ui.regionSelect.find().click().type(`${databaseRegionLabel}{enter}`); + ui.regionSelect.find().click(); + cy.focused().type(`${databaseRegionLabel}{enter}`); // Click either the "Dedicated CPU" or "Shared CPU" tab, according // to the type of cluster being created. diff --git a/packages/manager/cypress/e2e/core/databases/delete-database.spec.ts b/packages/manager/cypress/e2e/core/databases/delete-database.spec.ts index 453e50248c7..9d6565873c4 100644 --- a/packages/manager/cypress/e2e/core/databases/delete-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/delete-database.spec.ts @@ -2,8 +2,10 @@ * @file DBaaS integration tests for delete operations. */ -import { accountFactory, databaseFactory } from 'src/factories'; -import { randomNumber, randomIp } from 'support/util/random'; +import { + databaseConfigurations, + mockDatabaseNodeTypes, +} from 'support/constants/databases'; import { mockGetAccount } from 'support/intercepts/account'; import { mockDeleteDatabase, @@ -12,11 +14,11 @@ import { mockGetDatabaseTypes, } from 'support/intercepts/databases'; import { ui } from 'support/ui'; -import { - databaseClusterConfiguration, - databaseConfigurations, - mockDatabaseNodeTypes, -} from 'support/constants/databases'; +import { randomIp, randomNumber } from 'support/util/random'; + +import { accountFactory, databaseFactory } from 'src/factories'; + +import type { databaseClusterConfiguration } from 'support/constants/databases'; describe('Delete database clusters', () => { databaseConfigurations.forEach( @@ -30,13 +32,13 @@ describe('Delete database clusters', () => { it('Can delete active database clusters', () => { const allowedIp = randomIp(); const database = databaseFactory.build({ + allow_list: [allowedIp], + engine: configuration.dbType, id: randomNumber(1, 1000), - type: configuration.linodeType, label: configuration.label, region: configuration.region.id, - engine: configuration.dbType, status: 'active', - allow_list: [allowedIp], + type: configuration.linodeType, }); // Mock account to ensure 'Managed Databases' capability. @@ -61,7 +63,8 @@ describe('Delete database clusters', () => { .findByTitle(`Delete Database Cluster ${database.label}`) .should('be.visible') .within(() => { - cy.findByLabelText('Cluster Name').click().type(database.label); + cy.findByLabelText('Cluster Name').click(); + cy.focused().type(database.label); ui.buttonGroup .findButtonByTitle('Delete Cluster') @@ -82,17 +85,17 @@ describe('Delete database clusters', () => { */ it('Cannot delete provisioning database clusters', () => { const database = databaseFactory.build({ - id: randomNumber(1, 1000), - type: configuration.linodeType, - label: configuration.label, - region: configuration.region.id, - engine: configuration.dbType, - status: 'provisioning', allow_list: [], + engine: configuration.dbType, hosts: { primary: undefined, secondary: undefined, }, + id: randomNumber(1, 1000), + label: configuration.label, + region: configuration.region.id, + status: 'provisioning', + type: configuration.linodeType, }); const errorMessage = @@ -123,7 +126,8 @@ describe('Delete database clusters', () => { .findByTitle(`Delete Database Cluster ${database.label}`) .should('be.visible') .within(() => { - cy.findByLabelText('Cluster Name').click().type(database.label); + cy.findByLabelText('Cluster Name').click(); + cy.focused().type(database.label); ui.buttonGroup .findButtonByTitle('Delete Cluster') diff --git a/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts b/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts index 303aac90f97..d3222977fb0 100644 --- a/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/resize-database.spec.ts @@ -2,9 +2,11 @@ * @file DBaaS integration tests for resize operations. */ -import { randomNumber, randomIp, randomString } from 'support/util/random'; -import { databaseFactory, possibleStatuses } from 'src/factories/databases'; -import { ui } from 'support/ui'; +import { accountFactory } from '@src/factories'; +import { + databaseConfigurationsResize, + mockDatabaseNodeTypes, +} from 'support/constants/databases'; import { mockGetAccount } from 'support/intercepts/account'; import { mockGetDatabase, @@ -13,12 +15,12 @@ import { mockResize, mockResizeProvisioningDatabase, } from 'support/intercepts/databases'; -import { - databaseClusterConfiguration, - databaseConfigurationsResize, - mockDatabaseNodeTypes, -} from 'support/constants/databases'; -import { accountFactory } from '@src/factories'; +import { ui } from 'support/ui'; +import { randomIp, randomNumber, randomString } from 'support/util/random'; + +import { databaseFactory, possibleStatuses } from 'src/factories/databases'; + +import type { databaseClusterConfiguration } from 'support/constants/databases'; /** * Resizes a current database cluster to a larger plan size. @@ -39,7 +41,8 @@ const resizeDatabase = (initialLabel: string) => { .findByTitle(`Resize Database Cluster ${initialLabel}?`) .should('be.visible') .within(() => { - cy.findByLabelText('Cluster Name').click().type(initialLabel); + cy.findByLabelText('Cluster Name').click(); + cy.focused().type(initialLabel); ui.buttonGroup .findButtonByTitle('Resize Cluster') .should('be.visible') @@ -62,15 +65,15 @@ describe('Resizing existing clusters', () => { const allowedIp = randomIp(); const initialPassword = randomString(16); const database = databaseFactory.build({ + allow_list: [allowedIp], + cluster_size: 3, + engine: configuration.dbType, id: randomNumber(1, 1000), - type: configuration.linodeType, label: initialLabel, + platform: 'rdbms-legacy', region: configuration.region.id, - engine: configuration.dbType, - cluster_size: 3, status: 'active', - allow_list: [allowedIp], - platform: 'rdbms-legacy', + type: configuration.linodeType, }); // Mock account to ensure 'Managed Databases' capability. @@ -211,14 +214,14 @@ describe('Resizing existing clusters', () => { const allowedIp = randomIp(); const initialPassword = randomString(16); const database = databaseFactory.build({ + allow_list: [allowedIp], + cluster_size: 3, + engine: configuration.dbType, id: randomNumber(1, 1000), - type: configuration.linodeType, label: initialLabel, region: configuration.region.id, - engine: configuration.dbType, - cluster_size: 3, status: 'active', - allow_list: [allowedIp], + type: configuration.linodeType, }); // Mock account to ensure 'Managed Databases' capability. @@ -290,18 +293,18 @@ describe('Resizing existing clusters', () => { const initialLabel = configuration.label; const allowedIp = randomIp(); const database = databaseFactory.build({ - id: randomNumber(1, 1000), - type: configuration.linodeType, - label: initialLabel, - region: configuration.region.id, - engine: configuration.dbType, - cluster_size: 3, - status: dbstatus, allow_list: [allowedIp], + cluster_size: 3, + engine: configuration.dbType, hosts: { primary: undefined, secondary: undefined, }, + id: randomNumber(1, 1000), + label: initialLabel, + region: configuration.region.id, + status: dbstatus, + type: configuration.linodeType, }); const errorMessage = `Your database is ${dbstatus}; please wait until it becomes active to perform this operation.`; diff --git a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts index 44167766719..fd998f69319 100644 --- a/packages/manager/cypress/e2e/core/databases/update-database.spec.ts +++ b/packages/manager/cypress/e2e/core/databases/update-database.spec.ts @@ -2,14 +2,11 @@ * @file DBaaS integration tests for update operations. */ +import { accountFactory } from '@src/factories'; import { - randomLabel, - randomNumber, - randomIp, - randomString, -} from 'support/util/random'; -import { databaseFactory } from 'src/factories/databases'; -import { ui } from 'support/ui'; + databaseConfigurations, + mockDatabaseNodeTypes, +} from 'support/constants/databases'; import { mockGetAccount } from 'support/intercepts/account'; import { mockGetDatabase, @@ -20,13 +17,18 @@ import { mockUpdateDatabase, mockUpdateProvisioningDatabase, } from 'support/intercepts/databases'; -import { - databaseClusterConfiguration, - databaseConfigurations, - mockDatabaseNodeTypes, -} from 'support/constants/databases'; -import { accountFactory } from '@src/factories'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { ui } from 'support/ui'; +import { + randomIp, + randomLabel, + randomNumber, + randomString, +} from 'support/util/random'; + +import { databaseFactory } from 'src/factories/databases'; + +import type { databaseClusterConfiguration } from 'support/constants/databases'; /** * Updates a database cluster's label. @@ -46,11 +48,9 @@ const updateDatabaseLabel = (originalLabel: string, newLabel: string) => { cy.get('[data-qa-edit-field="true"]') .should('be.visible') .within(() => { - cy.get('[data-testid="textfield-input"]') - .should('be.visible') - .click() - .clear() - .type(newLabel); + cy.get('[data-testid="textfield-input"]').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(newLabel); cy.get('[data-qa-save-edit="true"]').should('be.visible').click(); }); @@ -108,9 +108,8 @@ const manageAccessControl = (allowedIps: string[], existingIps: number = 0) => { } cy.findByLabelText( `Allowed IP Addresses or Ranges ip-address-${index + existingIps}` - ) - .click() - .type(allowedIp); + ).click(); + cy.focused().type(allowedIp); }); ui.buttonGroup @@ -167,7 +166,7 @@ describe('Update database clusters', () => { ], }); mockAppendFeatureFlags({ - dbaasV2: { enabled: false, beta: false }, + dbaasV2: { beta: false, enabled: false }, }); mockGetAccount(mockAccount); }); @@ -189,14 +188,14 @@ describe('Update database clusters', () => { const newAllowedIp = randomIp(); const initialPassword = randomString(16); const database = databaseFactory.build({ + allow_list: [allowedIp], + engine: configuration.dbType, id: randomNumber(1, 1000), - type: configuration.linodeType, label: initialLabel, + platform: 'rdbms-legacy', region: configuration.region.id, - engine: configuration.dbType, status: 'active', - allow_list: [allowedIp], - platform: 'rdbms-legacy', + type: configuration.linodeType, }); mockGetDatabase(database).as('getDatabase'); @@ -301,18 +300,18 @@ describe('Update database clusters', () => { const updateAttemptLabel = randomLabel(); const allowedIp = randomIp(); const database = databaseFactory.build({ - id: randomNumber(1, 1000), - type: configuration.linodeType, - label: initialLabel, - region: configuration.region.id, - engine: configuration.dbType, - status: 'provisioning', allow_list: [allowedIp], + engine: configuration.dbType, hosts: { primary: undefined, secondary: undefined, }, + id: randomNumber(1, 1000), + label: initialLabel, platform: 'rdbms-legacy', + region: configuration.region.id, + status: 'provisioning', + type: configuration.linodeType, }); const errorMessage = diff --git a/packages/manager/cypress/e2e/core/domains/domains-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/domains/domains-empty-landing-page.spec.ts index d702b520062..ebcfb4ba55a 100644 --- a/packages/manager/cypress/e2e/core/domains/domains-empty-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/domains-empty-landing-page.spec.ts @@ -1,5 +1,5 @@ -import { ui } from 'support/ui'; import { mockGetDomains } from 'support/intercepts/domains'; +import { ui } from 'support/ui'; describe('Domains empty landing page', () => { /** diff --git a/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts index 2b3903f78de..e88631fda0f 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-clone-domain.spec.ts @@ -1,12 +1,13 @@ -import { Domain } from '@linode/api-v4'; +import { createDomain } from '@linode/api-v4/lib/domains'; import { domainFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; -import { randomDomainName } from 'support/util/random'; -import { createDomain } from '@linode/api-v4/lib/domains'; import { createDomainRecords } from 'support/constants/domains'; import { interceptCreateDomainRecord } from 'support/intercepts/domains'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; +import { randomDomainName } from 'support/util/random'; + +import type { Domain } from '@linode/api-v4'; authenticate(); describe('Clone a Domain', () => { @@ -45,7 +46,8 @@ describe('Clone a Domain', () => { interceptCreateDomainRecord().as('apiCreateRecord'); cy.findByText(rec.name).click(); rec.fields.forEach((f) => { - cy.get(f.name).click().type(f.value); + cy.get(f.name).click(); + cy.focused().type(f.value); }); cy.findByText('Save').click(); cy.wait('@apiCreateRecord'); @@ -101,7 +103,8 @@ describe('Clone a Domain', () => { .should('be.disabled'); // Confirm that an error is displayed when entering an invalid domain name - cy.findByLabelText('New Domain').click().type(invalidDomainName); + cy.findByLabelText('New Domain').click(); + cy.focused().type(invalidDomainName); ui.buttonGroup .findButtonByTitle('Create Domain') .should('be.visible') @@ -109,10 +112,9 @@ describe('Clone a Domain', () => { .click(); cy.findByText('Domain is not valid.').should('be.visible'); - cy.findByLabelText('New Domain') - .click() - .clear() - .type(clonedDomainName); + cy.findByLabelText('New Domain').click(); + cy.focused().clear(); + cy.focused().type(clonedDomainName); ui.buttonGroup .findButtonByTitle('Create Domain') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts index 22fd28b798a..71063492601 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-create-domain-records.spec.ts @@ -1,7 +1,7 @@ import { authenticate } from 'support/api/authentication'; import { createDomain } from 'support/api/domains'; -import { interceptCreateDomainRecord } from 'support/intercepts/domains'; import { createDomainRecords } from 'support/constants/domains'; +import { interceptCreateDomainRecord } from 'support/intercepts/domains'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; @@ -42,7 +42,8 @@ const editCaaRecord = (name: string, newValue: string) => { ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); // Edit the value field - cy.findByLabelText('Value').clear().type(newValue); + cy.findByLabelText('Value').clear(); + cy.focused().type(newValue); ui.button.findByTitle('Save').click(); }; diff --git a/packages/manager/cypress/e2e/core/domains/smoke-create-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-create-domain.spec.ts index 9f9e832cc58..d83cd99129e 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-create-domain.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-create-domain.spec.ts @@ -1,4 +1,3 @@ -import { Domain } from '@linode/api-v4'; import { domainFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { @@ -8,6 +7,8 @@ import { import { cleanUp } from 'support/util/cleanup'; import { randomDomainName } from 'support/util/random'; +import type { Domain } from '@linode/api-v4'; + authenticate(); describe('Create a Domain', () => { before(() => { diff --git a/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts index c6bdd8b30ae..e6962632185 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-delete-domain.spec.ts @@ -1,9 +1,10 @@ -import { Domain } from '@linode/api-v4'; +import { createDomain } from '@linode/api-v4/lib/domains'; import { domainFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; -import { randomDomainName } from 'support/util/random'; -import { createDomain } from '@linode/api-v4/lib/domains'; import { ui } from 'support/ui'; +import { randomDomainName } from 'support/util/random'; + +import type { Domain } from '@linode/api-v4'; authenticate(); beforeEach(() => { @@ -72,7 +73,8 @@ describe('Delete a Domain', () => { .findButtonByTitle('Delete Domain') .should('be.visible') .should('be.disabled'); - cy.contains('Domain Name').click().type(domain.domain); + cy.contains('Domain Name').click(); + cy.focused().type(domain.domain); ui.buttonGroup .findButtonByTitle('Delete Domain') diff --git a/packages/manager/cypress/e2e/core/domains/smoke-domain-download-zone-file.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-domain-download-zone-file.spec.ts index 4c5401df232..2a61163331c 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-domain-download-zone-file.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-domain-download-zone-file.spec.ts @@ -5,14 +5,14 @@ import { } from '@src/factories'; import { authenticate } from 'support/api/authentication'; import { - mockGetDomains, mockGetDomain, mockGetDomainRecords, mockGetDomainZoneFile, + mockGetDomains, } from 'support/intercepts/domains'; -import { randomDomainName } from 'support/util/random'; -import { readDownload } from 'support/util/downloads'; import { ui } from 'support/ui'; +import { readDownload } from 'support/util/downloads'; +import { randomDomainName } from 'support/util/random'; authenticate(); describe('Download a Zone file', () => { @@ -24,9 +24,9 @@ describe('Download a Zone file', () => { */ it('downloads a zone in the domain page', () => { const mockDomain = domainFactory.build({ - id: 123, domain: randomDomainName(), group: 'test-group', + id: 123, }); const mockDomainRecords = domainRecordFactory.build(); const mockDomainZoneFile = domainZoneFileFactory.build(); diff --git a/packages/manager/cypress/e2e/core/domains/smoke-domain-import-a-zone.spec.ts b/packages/manager/cypress/e2e/core/domains/smoke-domain-import-a-zone.spec.ts index 17587c4f1ae..e1d19040895 100644 --- a/packages/manager/cypress/e2e/core/domains/smoke-domain-import-a-zone.spec.ts +++ b/packages/manager/cypress/e2e/core/domains/smoke-domain-import-a-zone.spec.ts @@ -1,9 +1,10 @@ -import { ImportZonePayload } from '@linode/api-v4'; import { domainFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; -import { randomDomainName, randomIp } from 'support/util/random'; import { mockGetDomains, mockImportDomain } from 'support/intercepts/domains'; import { ui } from 'support/ui'; +import { randomDomainName, randomIp } from 'support/util/random'; + +import type { ImportZonePayload } from '@linode/api-v4'; authenticate(); describe('Import a Zone', () => { @@ -45,7 +46,9 @@ describe('Import a Zone', () => { .should('be.disabled'); // Verify only filling out Domain cannot import - cy.findByLabelText('Domain').click().clear().type(zone.domain); + cy.findByLabelText('Domain').click(); + cy.focused().clear(); + cy.focused().type(zone.domain); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') @@ -54,11 +57,12 @@ describe('Import a Zone', () => { cy.findByText('Remote nameserver is required.'); // Verify invalid domain cannot import - cy.findByLabelText('Domain').click().clear().type('1'); - cy.findByLabelText('Remote Nameserver') - .click() - .clear() - .type(zone.remote_nameserver); + cy.findByLabelText('Domain').click(); + cy.focused().clear(); + cy.focused().type('1'); + cy.findByLabelText('Remote Nameserver').click(); + cy.focused().clear(); + cy.focused().type(zone.remote_nameserver); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') @@ -67,11 +71,11 @@ describe('Import a Zone', () => { cy.findByText('Domain is not valid.'); // Verify only filling out RemoteNameserver cannot import - cy.findByLabelText('Domain').click().clear(); - cy.findByLabelText('Remote Nameserver') - .click() - .clear() - .type(zone.remote_nameserver); + cy.findByLabelText('Domain').click(); + cy.focused().clear(); + cy.findByLabelText('Remote Nameserver').click(); + cy.focused().clear(); + cy.focused().type(zone.remote_nameserver); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') @@ -80,8 +84,12 @@ describe('Import a Zone', () => { cy.findByText('Domain is required.'); // Verify invalid remote nameserver cannot import - cy.findByLabelText('Domain').click().clear().type(zone.domain); - cy.findByLabelText('Remote Nameserver').click().clear().type('1'); + cy.findByLabelText('Domain').click(); + cy.focused().clear(); + cy.focused().type(zone.domain); + cy.findByLabelText('Remote Nameserver').click(); + cy.focused().clear(); + cy.focused().type('1'); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') @@ -92,11 +100,12 @@ describe('Import a Zone', () => { // Fill out and import the zone. mockImportDomain(mockDomain).as('importDomain'); mockGetDomains([mockDomain]).as('getDomains'); - cy.findByLabelText('Domain').click().clear().type(zone.domain); - cy.findByLabelText('Remote Nameserver') - .click() - .clear() - .type(zone.remote_nameserver); + cy.findByLabelText('Domain').click(); + cy.focused().clear(); + cy.focused().type(zone.domain); + cy.findByLabelText('Remote Nameserver').click(); + cy.focused().clear(); + cy.focused().type(zone.remote_nameserver); ui.buttonGroup .findButtonByTitle('Import') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts index 3007167215a..314bc91aebf 100644 --- a/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/create-firewall.spec.ts @@ -1,11 +1,12 @@ -import { createTestLinode } from 'support/util/linodes'; -import { createLinodeRequestFactory } from 'src/factories/linodes'; import { authenticate } from 'support/api/authentication'; import { interceptCreateFirewall } from 'support/intercepts/firewalls'; -import { randomString, randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; -import { chooseRegion } from 'support/util/regions'; import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; +import { randomLabel, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +import { createLinodeRequestFactory } from 'src/factories/linodes'; authenticate(); describe('create firewall', () => { @@ -38,7 +39,8 @@ describe('create firewall', () => { cy.get('[data-testid="submit"]').click(); cy.findByText('Label is required.'); // Fill out and submit firewall create form. - cy.contains('Label').click().type(firewall.label); + cy.contains('Label').click(); + cy.focused().type(firewall.label); ui.buttonGroup .findButtonByTitle('Create Firewall') .should('be.visible') @@ -89,11 +91,10 @@ describe('create firewall', () => { .should('be.visible') .within(() => { // Fill out and submit firewall create form. - cy.contains('Label').click().type(firewall.label); - cy.findByLabelText('Linodes') - .should('be.visible') - .click() - .type(linode.label); + cy.contains('Label').click(); + cy.focused().type(firewall.label); + cy.findByLabelText('Linodes').should('be.visible').click(); + cy.focused().type(linode.label); ui.autocompletePopper .findByTitle(linode.label) diff --git a/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts index 8f55e035d5b..99bbabeb1a2 100644 --- a/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/delete-firewall.spec.ts @@ -1,9 +1,12 @@ -import { createFirewall, Firewall } from '@linode/api-v4'; -import { firewallFactory } from 'src/factories/firewalls'; +import { createFirewall } from '@linode/api-v4'; import { authenticate } from 'support/api/authentication'; -import { randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; +import { randomLabel } from 'support/util/random'; + +import { firewallFactory } from 'src/factories/firewalls'; + +import type { Firewall } from '@linode/api-v4'; authenticate(); describe('delete firewall', () => { diff --git a/packages/manager/cypress/e2e/core/firewalls/landing-page-empty-state.spec.ts b/packages/manager/cypress/e2e/core/firewalls/landing-page-empty-state.spec.ts index b95dd0b6452..ceeff489de1 100644 --- a/packages/manager/cypress/e2e/core/firewalls/landing-page-empty-state.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/landing-page-empty-state.spec.ts @@ -1,5 +1,5 @@ -import { ui } from 'support/ui'; import { mockGetFirewalls } from 'support/intercepts/firewalls'; +import { ui } from 'support/ui'; describe('confirms Firewalls landing page empty state is shown when no Firewalls exist', () => { /* diff --git a/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts index 2074a0785db..7ad3575f76e 100644 --- a/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/migrate-linode-with-firewall.spec.ts @@ -19,18 +19,19 @@ import { import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; -import { randomLabel, randomNumber } from 'support/util/random'; -import type { Linode, Region } from '@linode/api-v4'; -import { chooseRegions } from 'support/util/regions'; import { createTestLinode } from 'support/util/linodes'; +import { randomLabel, randomNumber } from 'support/util/random'; import { extendRegion } from 'support/util/regions'; +import { chooseRegions } from 'support/util/regions'; + +import type { Linode, Region } from '@linode/api-v4'; const mockDallas = extendRegion( regionFactory.build({ capabilities: ['Linodes', 'NodeBalancers', 'Block Storage'], id: 'us-central', - status: 'ok', label: 'Dallas, TX', + status: 'ok', }) ); @@ -39,8 +40,8 @@ const mockLondon = extendRegion( capabilities: ['Linodes', 'NodeBalancers', 'Block Storage'], country: 'uk', id: 'eu-west', - status: 'ok', label: 'London, UK', + status: 'ok', }) ); @@ -54,8 +55,8 @@ const mockSingapore = extendRegion( ], country: 'sg', id: 'ap-south', - status: 'ok', label: 'Singapore, SG', + status: 'ok', }) ); @@ -173,15 +174,11 @@ describe('Migrate Linode With Firewall', () => { .findByTitle('Create Firewall') .should('be.visible') .within(() => { - cy.findByText('Label') - .should('be.visible') - .click() - .type(firewallLabel); + cy.findByText('Label').should('be.visible').click(); + cy.focused().type(firewallLabel); - cy.findByText('Linodes') - .should('be.visible') - .click() - .type(linode.label); + cy.findByText('Linodes').should('be.visible').click(); + cy.focused().type(linode.label); ui.autocompletePopper .findByTitle(linode.label) diff --git a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts index 3aa89d01e85..930a90bf216 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -1,47 +1,49 @@ -import type { - Linode, - Firewall, - FirewallRuleType, - CreateLinodeRequest, - CreateFirewallPayload, - FirewallPolicyType, -} from '@linode/api-v4'; -import { createLinode, createFirewall } from '@linode/api-v4'; -import { - createLinodeRequestFactory, - firewallFactory, - firewallRuleFactory, - firewallRulesFactory, -} from 'src/factories'; +import { createFirewall, createLinode } from '@linode/api-v4'; import { authenticate } from 'support/api/authentication'; import { interceptUpdateFirewallLinodes, interceptUpdateFirewallRules, } from 'support/intercepts/firewalls'; -import { randomItem, randomString, randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; -import { chooseRegion } from 'support/util/regions'; import { cleanUp } from 'support/util/cleanup'; +import { randomItem, randomLabel, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +import { + createLinodeRequestFactory, + firewallFactory, + firewallRuleFactory, + firewallRulesFactory, +} from 'src/factories'; + +import type { + CreateFirewallPayload, + CreateLinodeRequest, + Firewall, + FirewallPolicyType, + FirewallRuleType, + Linode, +} from '@linode/api-v4'; const portPresetMap = { '22': 'SSH', + '53': 'DNS', '80': 'HTTP', '443': 'HTTPS', '3306': 'MySQL', - '53': 'DNS', }; const inboundRule = firewallRuleFactory.build({ - label: randomLabel(), - description: randomString(), action: 'ACCEPT', + description: randomString(), + label: randomLabel(), ports: randomItem(Object.keys(portPresetMap)), }); const outboundRule = firewallRuleFactory.build({ - label: randomLabel(), - description: randomString(), action: 'DROP', + description: randomString(), + label: randomLabel(), ports: randomItem(Object.keys(portPresetMap)), }); @@ -82,10 +84,10 @@ const addFirewallRules = (rule: FirewallRuleType, direction: string) => { const description = rule.description ? rule.description : 'test-description'; - cy.contains('Label') - .click() - .type('{selectall}{backspace}' + label); - cy.contains('Description').click().type(description); + cy.contains('Label').click(); + cy.focused().type('{selectall}{backspace}' + label); + cy.contains('Description').click(); + cy.focused().type(description); const action = rule.action ? getRuleActionLabel(rule.action) : 'Accept'; cy.contains(action).click(); @@ -139,10 +141,8 @@ const addLinodesToFirewall = (firewall: Firewall, linode: Linode) => { .should('be.visible') .within(() => { // Fill out and submit firewall edit form. - cy.findByLabelText('Linodes') - .should('be.visible') - .click() - .type(linode.label); + cy.findByLabelText('Linodes').should('be.visible').click(); + cy.focused().type(linode.label); ui.autocompletePopper .findByTitle(linode.label) @@ -160,6 +160,7 @@ const createLinodeAndFirewall = async ( firewallRequestPayload: CreateFirewallPayload ) => { return Promise.all([ + // eslint-disable-next-line @linode/cloud-manager/no-createLinode createLinode(linodeRequestPayload), createFirewall(firewallRequestPayload), ]); @@ -440,10 +441,9 @@ describe('update firewall', () => { cy.visitWithLogin(`/firewalls/${firewall.id}`); cy.findByLabelText(`Edit ${firewall.label}`).click(); - cy.get(`[id="edit-${firewall.label}-label"]`) - .click() - .clear() - .type(`${newFirewallLabel}{enter}`); + cy.get(`[id="edit-${firewall.label}-label"]`).click(); + cy.focused().clear(); + cy.focused().type(`${newFirewallLabel}{enter}`); // Confirm Firewall label updates in breadcrumbs. ui.entityHeader.find().within(() => { diff --git a/packages/manager/cypress/e2e/core/general/account-activation.spec.ts b/packages/manager/cypress/e2e/core/general/account-activation.spec.ts index 1c5273e999b..d8c688afd2a 100644 --- a/packages/manager/cypress/e2e/core/general/account-activation.spec.ts +++ b/packages/manager/cypress/e2e/core/general/account-activation.spec.ts @@ -11,7 +11,6 @@ describe('account activation', () => { */ it('should render an activation landing page if the customer is not activated', () => { cy.intercept('GET', apiMatcher('*'), { - statusCode: 403, body: { errors: [ { @@ -20,6 +19,7 @@ describe('account activation', () => { }, ], }, + statusCode: 403, }); cy.visitWithLogin('/'); diff --git a/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts b/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts index a7c3d3a465a..b67f7448da7 100644 --- a/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts +++ b/packages/manager/cypress/e2e/core/general/account-login-redirect.spec.ts @@ -1,5 +1,5 @@ -import { mockApiRequestWithError } from 'support/intercepts/general'; import { loginBaseUrl } from 'support/constants/login'; +import { mockApiRequestWithError } from 'support/intercepts/general'; describe('account login redirect', () => { /** @@ -15,7 +15,6 @@ describe('account login redirect', () => { cy.visitWithLogin('/linodes/create'); cy.url().should('contain', `${loginBaseUrl}/login?`, { exact: false }); - cy.findByText('Please log in to continue.').should('be.visible'); }); /** diff --git a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts index d3c7b34d712..69f9ff92b38 100644 --- a/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts +++ b/packages/manager/cypress/e2e/core/general/gdpr-agreement.spec.ts @@ -1,10 +1,11 @@ -import { ui } from 'support/ui'; import { linodeFactory, regionFactory } from '@src/factories'; -import { randomString, randomLabel } from 'support/util/random'; -import { mockGetRegions } from 'support/intercepts/regions'; import { mockGetAccountAgreements } from 'support/intercepts/account'; -import type { Region } from '@linode/api-v4'; import { mockCreateLinode } from 'support/intercepts/linodes'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; +import { randomLabel, randomString } from 'support/util/random'; + +import type { Region } from '@linode/api-v4'; const mockRegions: Region[] = [ regionFactory.build({ @@ -43,9 +44,9 @@ describe('GDPR agreement', () => { it('displays the GDPR agreement based on region, if user has not agreed yet', () => { mockGetRegions(mockRegions).as('getRegions'); mockGetAccountAgreements({ - privacy_policy: false, - eu_model: false, billing_agreement: false, + eu_model: false, + privacy_policy: false, }).as('getAgreements'); cy.visitWithLogin('/linodes/create'); @@ -72,9 +73,9 @@ describe('GDPR agreement', () => { it('does not display the GDPR agreement based on any region, if user has already agreed', () => { mockGetRegions(mockRegions).as('getRegions'); mockGetAccountAgreements({ - privacy_policy: false, - eu_model: true, billing_agreement: false, + eu_model: true, + privacy_policy: false, }).as('getAgreements'); cy.visitWithLogin('/linodes/create'); @@ -101,9 +102,9 @@ describe('GDPR agreement', () => { it('needs the agreement checked to submit the form', () => { mockGetRegions(mockRegions).as('getRegions'); mockGetAccountAgreements({ - privacy_policy: false, - eu_model: false, billing_agreement: false, + eu_model: false, + privacy_policy: false, }).as('getAgreements'); const rootpass = randomString(32); const linodeLabel = randomLabel(); @@ -121,16 +122,18 @@ describe('GDPR agreement', () => { cy.get('[id="g6-nanode-1"]').click(); - cy.findByLabelText('Linode Label').clear().type(linodeLabel); + cy.findByLabelText('Linode Label').clear(); + cy.focused().type(linodeLabel); cy.findByLabelText('Root Password').type(rootpass); cy.get('[data-testid="eu-agreement-checkbox"]') - .scrollIntoView() - .should('be.visible'); + .as('euAgreement') + .scrollIntoView(); + cy.get('@euAgreement').should('be.visible'); - cy.findByText('Create Linode') - .scrollIntoView() + cy.findByText('Create Linode').as('lblCreateLinode').scrollIntoView(); + cy.get('@lblCreateLinode') .should('be.enabled') .should('be.visible') .click(); diff --git a/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts b/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts index e11f5707ae6..8cbe786110b 100644 --- a/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts +++ b/packages/manager/cypress/e2e/core/general/smoke-deep-link.spec.ts @@ -1,4 +1,5 @@ import { pages } from 'support/ui/constants'; + import type { Page } from 'support/ui/constants'; beforeEach(() => { diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/close-support-ticket.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/close-support-ticket.spec.ts index 46f3867d7a1..fcfa334b637 100644 --- a/packages/manager/cypress/e2e/core/helpAndSupport/close-support-ticket.spec.ts +++ b/packages/manager/cypress/e2e/core/helpAndSupport/close-support-ticket.spec.ts @@ -1,5 +1,15 @@ import 'cypress-file-upload'; +import { + closableMessage, + closeButtonText, +} from 'support/constants/help-and-support'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockCloseSupportTicket, + mockGetSupportTicket, + mockGetSupportTicketReplies, + mockGetSupportTickets, +} from 'support/intercepts/support'; import { ui } from 'support/ui'; import { randomItem, @@ -7,18 +17,9 @@ import { randomNumber, randomPhrase, } from 'support/util/random'; + import { supportTicketFactory } from 'src/factories'; -import { - mockGetSupportTicket, - mockGetSupportTickets, - mockGetSupportTicketReplies, - mockCloseSupportTicket, -} from 'support/intercepts/support'; import { SEVERITY_LABEL_MAP } from 'src/features/Support/SupportTickets/constants'; -import { - closableMessage, - closeButtonText, -} from 'support/constants/help-and-support'; describe('close support tickets', () => { /* @@ -27,11 +28,11 @@ describe('close support tickets', () => { */ it('cannot close a default support ticket by customers', () => { const mockTicket = supportTicketFactory.build({ - id: randomNumber(), - summary: randomLabel(), description: randomPhrase(), + id: randomNumber(), severity: randomItem([1, 2, 3]), status: 'new', + summary: randomLabel(), }); // Get severity label for numeric severity level. @@ -78,18 +79,18 @@ describe('close support tickets', () => { */ it('can close a closable support ticket', () => { const mockTicket = supportTicketFactory.build({ - id: randomNumber(), - summary: randomLabel(), + closable: true, description: randomPhrase(), + id: randomNumber(), severity: randomItem([1, 2, 3]), status: 'new', - closable: true, + summary: randomLabel(), }); const mockClosedTicket = supportTicketFactory.build({ ...mockTicket, - status: 'closed', closed: 'close by customers', + status: 'closed', }); // Get severity label for numeric severity level. diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts index 0b012dd54d9..2efb32ab3d2 100644 --- a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts +++ b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts @@ -1,9 +1,26 @@ -/* eslint-disable prettier/prettier */ +// must turn off sort-objects rule in this file bc mockTicket.description is set by formatDescription fn in which attribute order is nonalphabetical and affects test result +/* eslint-disable perfectionist/sort-objects */ /* eslint-disable sonarjs/no-duplicate-string */ import 'cypress-file-upload'; -import { interceptGetProfile } from 'support/intercepts/profile'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetDomains } from 'support/intercepts/domains'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockCreateLinodeAccountLimitError, + mockGetLinodeDetails, + mockGetLinodes, +} from 'support/intercepts/linodes'; +import { mockGetClusters } from 'support/intercepts/lke'; +import { interceptGetProfile } from 'support/intercepts/profile'; +import { + mockAttachSupportTicketFile, + mockCreateSupportTicket, + mockGetSupportTicket, + mockGetSupportTicketReplies, + mockGetSupportTickets, +} from 'support/intercepts/support'; import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; import { randomItem, randomLabel, @@ -11,19 +28,14 @@ import { randomPhrase, randomString, } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + import { accountFactory, domainFactory, linodeFactory, supportTicketFactory, } from 'src/factories'; -import { - mockAttachSupportTicketFile, - mockCreateSupportTicket, - mockGetSupportTicket, - mockGetSupportTickets, - mockGetSupportTicketReplies, -} from 'support/intercepts/support'; import { ACCOUNT_LIMIT_DIALOG_TITLE, ACCOUNT_LIMIT_FIELD_NAME_TO_LABEL_MAP, @@ -34,20 +46,11 @@ import { SMTP_HELPER_TEXT, } from 'src/features/Support/SupportTickets/constants'; import { formatDescription } from 'src/features/Support/SupportTickets/ticketUtils'; -import { mockGetAccount } from 'support/intercepts/account'; -import { + +import type { EntityType, TicketType, } from 'src/features/Support/SupportTickets/SupportTicketDialog'; -import { - mockCreateLinodeAccountLimitError, - mockGetLinodeDetails, - mockGetLinodes, -} from 'support/intercepts/linodes'; -import { mockGetDomains } from 'support/intercepts/domains'; -import { mockGetClusters } from 'support/intercepts/lke'; -import { linodeCreatePage } from 'support/ui/pages'; -import { chooseRegion } from 'support/util/regions'; describe('open support tickets', () => { /* @@ -97,9 +100,10 @@ describe('open support tickets', () => { mockAttachSupportTicketFile(ticketId).as('attachmentPost'); cy.contains('Open New Ticket').click(); - cy.get('input[placeholder="Enter a title for your ticket."]') - .click({ scrollBehavior: false }) - .type(ticketLabel); + cy.get('input[placeholder="Enter a title for your ticket."]').click({ + scrollBehavior: false, + }); + cy.focused().type(ticketLabel); cy.findByLabelText('Severity').should('not.exist'); ui.autocomplete .findByLabel('What is this regarding?') @@ -109,9 +113,8 @@ describe('open support tickets', () => { .findByTitle('General/Account/Billing') .should('be.visible') .click(); - cy.get('[data-qa-ticket-description="true"]') - .click() - .type(ticketDescription); + cy.get('[data-qa-ticket-description="true"]').click(); + cy.focused().type(ticketDescription); cy.get('[id="attach-file"]').attachFile(image); cy.get('[value="test_screenshot.png"]').should('be.visible'); cy.get('[data-qa-submit="true"]').click(); @@ -178,18 +181,14 @@ describe('open support tickets', () => { .within(() => { cy.findByLabelText('Title', { exact: false }) .should('be.visible') - .click() - .type(mockTicket.summary); + .click(); + cy.focused().type(mockTicket.summary); - cy.findByLabelText('Severity') - .should('be.visible') - .click() - .type(`${mockTicket.severity}{downarrow}{enter}`); + cy.findByLabelText('Severity').should('be.visible').click(); + cy.focused().type(`${mockTicket.severity}{downarrow}{enter}`); - cy.get('[data-qa-ticket-description]') - .should('be.visible') - .click() - .type(mockTicket.description); + cy.get('[data-qa-ticket-description]').should('be.visible').click(); + cy.focused().type(mockTicket.description); ui.button .findByTitle('Open Ticket') @@ -300,20 +299,14 @@ describe('open support tickets', () => { cy.findByText('Links to public information are required.'); // Complete the rest of the form. - cy.get('[data-qa-ticket-use-case]') - .should('be.visible') - .click() - .type(mockFormFields.useCase); + cy.get('[data-qa-ticket-use-case]').should('be.visible').click(); + cy.focused().type(mockFormFields.useCase); - cy.get('[data-qa-ticket-email-domains]') - .should('be.visible') - .click() - .type(mockFormFields.emailDomains); + cy.get('[data-qa-ticket-email-domains]').should('be.visible').click(); + cy.focused().type(mockFormFields.emailDomains); - cy.get('[data-qa-ticket-public-info]') - .should('be.visible') - .click() - .type(mockFormFields.publicInfo); + cy.get('[data-qa-ticket-public-info]').should('be.visible').click(); + cy.focused().type(mockFormFields.publicInfo); // Confirm there is no description field or file upload section. cy.findByText('Description').should('not.exist'); @@ -471,18 +464,14 @@ describe('open support tickets', () => { // Complete the rest of the form. cy.findByLabelText('Total number of Linodes you need?') .should('be.visible') - .click() - .type(mockFormFields.numberOfEntities); + .click(); + cy.focused().type(mockFormFields.numberOfEntities); - cy.get('[data-qa-ticket-use-case]') - .should('be.visible') - .click() - .type(mockFormFields.useCase); + cy.get('[data-qa-ticket-use-case]').should('be.visible').click(); + cy.focused().type(mockFormFields.useCase); - cy.get('[data-qa-ticket-public-info]') - .should('be.visible') - .click() - .type(mockFormFields.publicInfo); + cy.get('[data-qa-ticket-public-info]').should('be.visible').click(); + cy.focused().type(mockFormFields.publicInfo); // Confirm there is no description field or file upload section. cy.findByText('Description').should('not.exist'); @@ -557,17 +546,14 @@ describe('open support tickets', () => { .within(() => { cy.findByLabelText('Title', { exact: false }) .should('be.visible') - .click() - .type(mockTicket.summary); + .click(); + cy.focused().type(mockTicket.summary); - cy.get('[data-qa-ticket-description]') - .should('be.visible') - .click() - .type(mockTicket.description); + cy.get('[data-qa-ticket-description]').should('be.visible').click(); + cy.focused().type(mockTicket.description); - cy.get('[data-qa-ticket-entity-type]') - .click() - .type(`Linodes{downarrow}{enter}`); + cy.get('[data-qa-ticket-entity-type]').click(); + cy.focused().type(`Linodes{downarrow}{enter}`); // Attempt to submit the form without an entity selected and confirm validation error. ui.button @@ -578,9 +564,8 @@ describe('open support tickets', () => { cy.findByText('Please select a Linode.').should('be.visible'); // Select an entity type for which there are no entities. - cy.get('[data-qa-ticket-entity-type]') - .click() - .type(`Kubernetes{downarrow}{enter}`); + cy.get('[data-qa-ticket-entity-type]').click(); + cy.focused().type(`Kubernetes{downarrow}{enter}`); // Confirm the validation error clears when a new entity type is selected. cy.findByText('Please select a Linode.').should('not.exist'); @@ -594,15 +579,12 @@ describe('open support tickets', () => { .should('be.disabled'); // Select another entity type. - cy.get('[data-qa-ticket-entity-type]') - .click() - .type(`{selectall}{del}Domains{uparrow}{enter}`); + cy.get('[data-qa-ticket-entity-type]').click(); + cy.focused().type(`{selectall}{del}Domains{uparrow}{enter}`); // Select an entity. - cy.get('[data-qa-ticket-entity-id]') - .should('be.visible') - .click() - .type(`${mockDomain.domain}{downarrow}{enter}`); + cy.get('[data-qa-ticket-entity-id]').should('be.visible').click(); + cy.focused().type(`${mockDomain.domain}{downarrow}{enter}`); ui.button .findByTitle('Open Ticket') diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts index 7fc84b7a74b..d1dbab44d9b 100644 --- a/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/helpAndSupport/support-tickets-landing-page.spec.ts @@ -1,32 +1,34 @@ -import { interceptGetProfile } from 'support/intercepts/profile'; +import { linodeConfigInterfaceFactory } from '@linode/utilities'; +import { mockGetLinodeConfigs } from 'support/intercepts/configs'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetLinodeDetails, + mockGetLinodeDisks, + mockGetLinodeVolumes, +} from 'support/intercepts/linodes'; +import { interceptGetProfile } from 'support/intercepts/profile'; +import { + mockGetSupportTicket, + mockGetSupportTicketReplies, + mockGetSupportTickets, +} from 'support/intercepts/support'; import { randomItem, randomLabel, randomNumber, randomPhrase, } from 'support/util/random'; + import { entityFactory, + linodeConfigFactory, linodeFactory, supportTicketFactory, volumeFactory, - linodeConfigFactory, - LinodeConfigInterfaceFactory, } from 'src/factories'; -import { - mockGetSupportTicket, - mockGetSupportTickets, - mockGetSupportTicketReplies, -} from 'support/intercepts/support'; import { SEVERITY_LABEL_MAP } from 'src/features/Support/SupportTickets/constants'; -import { mockGetLinodeConfigs } from 'support/intercepts/configs'; -import { - mockGetLinodeDetails, - mockGetLinodeVolumes, - mockGetLinodeDisks, -} from 'support/intercepts/linodes'; -import { Config, Disk } from '@linode/api-v4'; + +import type { Config, Disk } from '@linode/api-v4'; describe('support tickets landing page', () => { /* @@ -69,19 +71,19 @@ describe('support tickets landing page', () => { it('lists support tickets in the table as expected', () => { // TODO Integrate this test with the above test when feature flag goes away. const mockTicket = supportTicketFactory.build({ - id: randomNumber(), - summary: randomLabel(), description: randomPhrase(), + id: randomNumber(), severity: randomItem([1, 2, 3]), status: 'new', + summary: randomLabel(), }); const mockAnotherTicket = supportTicketFactory.build({ - id: randomNumber(), - summary: randomLabel(), description: randomPhrase(), + id: randomNumber(), severity: randomItem([1, 2, 3]), status: 'open', + summary: randomLabel(), }); const mockTickets = [mockTicket, mockAnotherTicket]; @@ -133,11 +135,11 @@ describe('support tickets landing page', () => { it("can navigate to the ticket's page when clicking on the ticket subject", () => { // TODO Integrate this test with the above test when feature flag goes away. const mockTicket = supportTicketFactory.build({ - id: randomNumber(), - summary: randomLabel(), description: randomPhrase(), + id: randomNumber(), severity: randomItem([1, 2, 3]), status: 'new', + summary: randomLabel(), }); // Get severity label for numeric severity level. @@ -186,7 +188,7 @@ describe('support tickets landing page', () => { label: `${randomLabel()}-linode`, }); const mockVolume = volumeFactory.build(); - const mockPublicConfigInterface = LinodeConfigInterfaceFactory.build({ + const mockPublicConfigInterface = linodeConfigInterfaceFactory.build({ ipam_address: null, purpose: 'public', }); @@ -199,22 +201,22 @@ describe('support tickets landing page', () => { }); const mockDisks: Disk[] = [ { - id: 44311273, - status: 'ready', - label: 'Debian 10 Disk', created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:30', filesystem: 'ext4', + id: 44311273, + label: 'Debian 10 Disk', size: 81408, + status: 'ready', + updated: '2020-08-21T17:26:30', }, { - id: 44311274, - status: 'ready', - label: '512 MB Swap Image', created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:31', filesystem: 'swap', + id: 44311274, + label: '512 MB Swap Image', size: 512, + status: 'ready', + updated: '2020-08-21T17:26:31', }, ]; @@ -226,12 +228,12 @@ describe('support tickets landing page', () => { }); const mockTicket = supportTicketFactory.build({ - id: randomNumber(), - entity: mockEntity, - summary: `${randomLabel()}-support-ticket`, description: randomPhrase(), + entity: mockEntity, + id: randomNumber(), severity: randomItem([1, 2, 3]), status: 'new', + summary: `${randomLabel()}-support-ticket`, }); // Get severity label for numeric severity level. diff --git a/packages/manager/cypress/e2e/core/images/create-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-image.spec.ts index 06cf3fa0e47..b71bf8fd0ba 100644 --- a/packages/manager/cypress/e2e/core/images/create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-image.spec.ts @@ -1,10 +1,11 @@ -import type { Linode } from '@linode/api-v4'; import { authenticate } from 'support/api/authentication'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; import { randomLabel, randomPhrase } from 'support/util/random'; +import type { Linode } from '@linode/api-v4'; + authenticate(); describe('create image (e2e)', () => { before(() => { @@ -31,8 +32,8 @@ describe('create image (e2e)', () => { .should('be.visible') .should('be.enabled') .should('have.attr', 'placeholder', 'Select a Linode') - .click() - .type(linode.label); + .click(); + cy.focused().type(linode.label); // Select the Linode ui.autocompletePopper @@ -54,8 +55,8 @@ describe('create image (e2e)', () => { cy.findByLabelText('Label') .should('be.enabled') .should('be.visible') - .clear() - .type(label); + .clear(); + cy.focused().type(label); // Give the Image a description cy.findByLabelText('Description') diff --git a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts index da4f61f4efb..efcf8d1224f 100644 --- a/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-linode-from-image.spec.ts @@ -1,22 +1,22 @@ +import { imageFactory, linodeFactory } from '@src/factories'; +import { mockGetAllImages } from 'support/intercepts/images'; +import { ui } from 'support/ui'; import { apiMatcher } from 'support/util/intercepts'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; -import { mockGetAllImages } from 'support/intercepts/images'; -import { imageFactory, linodeFactory } from '@src/factories'; import { chooseRegion } from 'support/util/regions'; -import { ui } from 'support/ui'; const region = chooseRegion(); const mockLinode = linodeFactory.build({ - region: region.id, id: 123456, + region: region.id, }); const mockImage = imageFactory.build({ - label: randomLabel(), - is_public: false, eol: null, id: `private/${randomNumber()}`, + is_public: false, + label: randomLabel(), }); const createLinodeWithImageMock = (url: string, preselectedImage: boolean) => { diff --git a/packages/manager/cypress/e2e/core/images/images-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/images/images-empty-landing-page.spec.ts index 3f7eb49d61d..49a4a3e3665 100644 --- a/packages/manager/cypress/e2e/core/images/images-empty-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/images/images-empty-landing-page.spec.ts @@ -1,13 +1,13 @@ -import { ui } from 'support/ui'; -import { mockGetAllImages } from 'support/intercepts/images'; import { profileFactory } from '@src/factories'; import { accountUserFactory } from '@src/factories/accountUsers'; import { grantsFactory } from '@src/factories/grants'; import { mockGetUser } from 'support/intercepts/account'; +import { mockGetAllImages } from 'support/intercepts/images'; import { mockGetProfile, mockGetProfileGrants, } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; describe('Images empty landing page', () => { @@ -67,15 +67,15 @@ describe('Images empty landing page', () => { it('checks restricted user has no access to create Image on Image landing page', () => { // object to create a mockProfile for non-restricted user const mockProfile = profileFactory.build({ - username: randomLabel(), restricted: true, + username: randomLabel(), }); // object to create a mockUser for non-restricted user const mockUser = accountUserFactory.build({ - username: mockProfile.username, restricted: true, user_type: 'default', + username: mockProfile.username, }); // object to create a mockGrants for non-restricted user diff --git a/packages/manager/cypress/e2e/core/images/images-non-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/images/images-non-empty-landing-page.spec.ts index 5154138d6f8..394ebe42563 100644 --- a/packages/manager/cypress/e2e/core/images/images-non-empty-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/images/images-non-empty-landing-page.spec.ts @@ -1,16 +1,18 @@ -import { imageFactory } from 'src/factories'; -import { ui } from 'support/ui'; -import { mockGetAllImages } from 'support/intercepts/images'; -import { profileFactory } from 'src/factories'; -import { randomLabel } from 'support/util/random'; -import { grantsFactory } from 'src/factories'; -import { accountUserFactory } from 'src/factories'; import { mockGetUser } from 'support/intercepts/account'; +import { mockGetAllImages } from 'support/intercepts/images'; import { mockGetProfile, mockGetProfileGrants, } from 'support/intercepts/profile'; -import { Image } from '@linode/api-v4'; +import { ui } from 'support/ui'; +import { randomLabel } from 'support/util/random'; + +import { grantsFactory } from 'src/factories'; +import { profileFactory } from 'src/factories'; +import { accountUserFactory } from 'src/factories'; +import { imageFactory } from 'src/factories'; + +import type { Image } from '@linode/api-v4'; function checkActionMenu(tableAlias: string, mockImages: any[]) { mockImages.forEach((image) => { @@ -67,14 +69,14 @@ describe('image landing checks for non-empty state with restricted user', () => it('checks restricted user with read access has no access to create image and can see existing images', () => { // Mock setup for user profile, account user, and user grants with restricted permissions, const mockProfile = profileFactory.build({ - username: randomLabel(), restricted: true, + username: randomLabel(), }); const mockUser = accountUserFactory.build({ - username: mockProfile.username, restricted: true, user_type: 'default', + username: mockProfile.username, }); const mockGrants = grantsFactory.build({ 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 8f8e2ecbf55..5d78e70bfcf 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 @@ -1,15 +1,13 @@ -import { EventStatus } from '@linode/api-v4'; import { eventFactory, imageFactory } from '@src/factories'; import { makeResourcePage } from '@src/mocks/serverHandlers'; import 'cypress-file-upload'; -import { RecPartial } from 'factory.ts'; import { DateTime } from 'luxon'; import { authenticate } from 'support/api/authentication'; import { mockDeleteImage, mockGetCustomImages, - mockUpdateImage, mockGetImage, + mockUpdateImage, } from 'support/intercepts/images'; import { ui } from 'support/ui'; import { interceptOnce } from 'support/ui/common'; @@ -18,6 +16,9 @@ import { apiMatcher } from 'support/util/intercepts'; import { randomLabel, randomPhrase } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import type { EventStatus } from '@linode/api-v4'; +import type { RecPartial } from 'factory.ts'; + /** * Returns a numeric image ID from a string-based image ID. * @@ -57,17 +58,17 @@ const eventIntercept = ( apiMatcher('account/events*'), makeResourcePage( eventFactory.buildList(1, { - created: created ? created : DateTime.local().toISO(), action: 'image_upload', + created: created ? created : DateTime.local().toISO(), entity: { - label: label, id: numericId, + label, type: 'image', url: `/v4/images/private/${numericId}`, }, - status, - secondary_entity: null, message: message ? message : '', + secondary_entity: null, + status, }) ) ).as('getEvent'); @@ -116,11 +117,11 @@ const uploadImage = (label: string) => { const upload = 'machine-images/test-image.gz'; cy.visitWithLogin('/images/create/upload'); - cy.findByLabelText('Label').click().type(label); + cy.findByLabelText('Label').click(); + cy.focused().type(label); - cy.findByLabelText('Description') - .click() - .type('This is a machine image upload test'); + cy.findByLabelText('Description').click(); + cy.focused().type('This is a machine image upload test'); ui.regionSelect.find().click(); ui.regionSelect.findItemByRegionId(region.id).click(); @@ -166,8 +167,8 @@ describe('machine image', () => { const mockImageUpdated = { ...mockImage, - label: updatedLabel, description: updatedDescription, + label: updatedLabel, }; mockGetCustomImages([mockImage]).as('getImages'); @@ -195,15 +196,11 @@ describe('machine image', () => { .findByTitle('Edit Image') .should('be.visible') .within(() => { - cy.findByLabelText('Label') - .should('be.visible') - .clear() - .type(updatedLabel); + cy.findByLabelText('Label').should('be.visible').clear(); + cy.focused().type(updatedLabel); - cy.findByLabelText('Description') - .should('be.visible') - .clear() - .type(updatedDescription); + cy.findByLabelText('Description').should('be.visible').clear(); + cy.focused().type(updatedDescription); ui.buttonGroup .findButtonByTitle('Save Changes') @@ -260,7 +257,7 @@ describe('machine image', () => { const imageId = xhr.response?.body.image.id; assertProcessing(label, imageId); mockGetCustomImages([ - imageFactory.build({ label, id: imageId, status: 'available' }), + imageFactory.build({ id: imageId, label, status: 'available' }), ]).as('getImages'); eventIntercept(label, imageId, status); ui.toast.assertMessage(uploadMessage); diff --git a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts index 72d984d6c4b..215ebb55219 100644 --- a/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts +++ b/packages/manager/cypress/e2e/core/images/manage-image-regions.spec.ts @@ -1,15 +1,17 @@ -import { imageFactory, regionFactory } from 'src/factories'; import { mockGetCustomImages, + mockGetImage, mockGetRecoveryImages, mockUpdateImageRegions, - mockGetImage, } from 'support/intercepts/images'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; -import type { Image, Region } from '@linode/api-v4'; import { extendRegion } from 'support/util/regions'; +import { imageFactory, regionFactory } from 'src/factories'; + +import type { Image, Region } from '@linode/api-v4'; + describe('Manage Image Replicas', () => { /** * Adds two new regions to an Image (region3 and region4) @@ -17,8 +19,8 @@ describe('Manage Image Replicas', () => { */ it("updates an Image's regions", () => { const regionOptions: Partial = { - site_type: 'core', capabilities: ['Object Storage'], + site_type: 'core', }; const region1 = extendRegion(regionFactory.build(regionOptions)); const region2 = extendRegion(regionFactory.build(regionOptions)); @@ -26,13 +28,13 @@ describe('Manage Image Replicas', () => { const region4 = extendRegion(regionFactory.build(regionOptions)); const image = imageFactory.build({ - size: 50, - total_size: 100, capabilities: ['distributed-sites'], regions: [ { region: region1.id, status: 'available' }, { region: region2.id, status: 'available' }, ], + size: 50, + total_size: 100, }); mockGetRegions([region1, region2, region3, region4]).as('getRegions'); @@ -131,12 +133,12 @@ describe('Manage Image Replicas', () => { const updatedImage: Image = { ...image, - total_size: 150, regions: [ { region: region2.id, status: 'available' }, { region: region3.id, status: 'pending replication' }, { region: region4.id, status: 'pending replication' }, ], + total_size: 150, }; // mock the POST /v4/images/:id:regions response diff --git a/packages/manager/cypress/e2e/core/images/search-images.spec.ts b/packages/manager/cypress/e2e/core/images/search-images.spec.ts index a9cd4d5ceaa..c0c60975113 100644 --- a/packages/manager/cypress/e2e/core/images/search-images.spec.ts +++ b/packages/manager/cypress/e2e/core/images/search-images.spec.ts @@ -1,12 +1,12 @@ import { createImage } from '@linode/api-v4/lib/images'; -import { createTestLinode } from 'support/util/linodes'; -import { ui } from 'support/ui'; - import { authenticate } from 'support/api/authentication'; -import { randomLabel } from 'support/util/random'; +import { interceptGetLinodeDisks } from 'support/intercepts/linodes'; +import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; +import { randomLabel } from 'support/util/random'; + import type { Image, Linode } from '@linode/api-v4'; -import { interceptGetLinodeDisks } from 'support/intercepts/linodes'; authenticate(); describe('Search Images', () => { 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 4c981b2f3ce..1c469ca07a4 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 @@ -1,34 +1,46 @@ -import { eventFactory, linodeFactory } from 'src/factories'; -import { linodeDiskFactory } from 'src/factories/disk'; -import { imageFactory } from 'src/factories/images'; +import { mockGetUser } from 'support/intercepts/account'; import { mockGetEvents } from 'support/intercepts/events'; import { mockCreateImage } from 'support/intercepts/images'; import { mockGetLinodeDisks, mockGetLinodes } from 'support/intercepts/linodes'; +import { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; import { ui } from 'support/ui'; import { randomLabel, randomNumber, randomPhrase } from 'support/util/random'; +import { + accountUserFactory, + eventFactory, + grantsFactory, + linodeFactory, + profileFactory, +} from 'src/factories'; +import { linodeDiskFactory } from 'src/factories/disk'; +import { imageFactory } from 'src/factories/images'; + describe('create image (using mocks)', () => { it('create image from a linode', () => { const mockDisks = [ - linodeDiskFactory.build({ label: 'Debian 12 Disk', filesystem: 'ext4' }), + linodeDiskFactory.build({ filesystem: 'ext4', label: 'Debian 12 Disk' }), linodeDiskFactory.build({ - label: '512 MB Swap Image', filesystem: 'swap', + label: '512 MB Swap Image', }), ]; const mockLinode = linodeFactory.build(); const mockNewImage = imageFactory.build({ + description: randomPhrase(), + eol: null, + expiry: null, id: `private/${randomNumber(1000, 99999)}`, + is_public: false, label: randomLabel(), - description: randomPhrase(), + status: 'creating', type: 'manual', - is_public: false, vendor: null, - expiry: null, - eol: null, - status: 'creating', }); mockGetLinodes([mockLinode]).as('getLinodes'); @@ -72,8 +84,8 @@ describe('create image (using mocks)', () => { cy.findByLabelText('Label') .should('be.enabled') .should('be.visible') - .clear() - .type(mockNewImage.label); + .clear(); + cy.focused().type(mockNewImage.label); // Give the Image a description cy.findByLabelText('Description') @@ -109,4 +121,146 @@ describe('create image (using mocks)', () => { // Verify a success toast shows ui.toast.assertMessage('Image My Config has been created.'); }); + + it('should not create image for the restricted users', () => { + // Mock setup for user profile, account user, and user grants with restricted permissions, + // simulating a default user without the ability to add Linodes. + const mockProfile = profileFactory.build({ + restricted: true, + username: randomLabel(), + }); + + const mockUser = accountUserFactory.build({ + restricted: true, + user_type: 'default', + username: mockProfile.username, + }); + + const mockGrants = grantsFactory.build({ + global: { + add_images: false, + }, + }); + + const mockDisks = [ + linodeDiskFactory.build({ filesystem: 'ext4', label: 'Debian 12 Disk' }), + linodeDiskFactory.build({ + filesystem: 'swap', + label: '512 MB Swap Image', + }), + ]; + + const mockLinode = linodeFactory.build(); + + mockGetProfile(mockProfile); + mockGetProfileGrants(mockGrants); + mockGetUser(mockUser); + mockGetLinodes([mockLinode]).as('getLinodes'); + mockGetLinodeDisks(mockLinode.id, mockDisks).as('getDisks'); + + cy.visitWithLogin('/images/create'); + + // Wait for Linodes to load + cy.wait('@getLinodes'); + + // Check the following fields are disable + + // Confirm that a notice should be shown informing the user they do not have permission to create a Linode + cy.findByText( + "You don't have permissions to create Images. Please contact your account administrator to request the necessary permissions." + ).should('be.visible'); + + // Confirm that "Linode" field is diabled + cy.get('[data-qa-autocomplete="Linode"]').within(() => { + cy.get('[title="Open"]').should('be.visible').should('be.disabled'); + }); + + // Confirm that "Disk" field is disabled + cy.get('[data-qa-autocomplete="Disk"]').within(() => { + cy.get('[title="Open"]').should('be.visible').should('be.disabled'); + }); + + // Confirm that "Label" field is disabled + cy.get('[id="label"]').should('be.visible').should('be.disabled'); + + // Confirm that "Add Tags" field is disabled + cy.get('[data-qa-autocomplete="Add Tags"]').within(() => { + cy.get('[title="Open"]').should('be.visible').should('be.disabled'); + }); + + // Confirm that "Description" field is disabled + cy.get('[id="description"]').should('be.visible').should('be.disabled'); + + // Confirm that "Create Image" button is disabled + ui.button + .findByTitle('Create Image') + .should('be.visible') + .should('be.disabled'); + }); + + it('should not upload image for the restricted users', () => { + const mockProfile = profileFactory.build({ + restricted: true, + username: randomLabel(), + }); + + const mockUser = accountUserFactory.build({ + restricted: true, + user_type: 'default', + username: mockProfile.username, + }); + + const mockGrants = grantsFactory.build({ + global: { + add_images: false, + }, + }); + + mockGetProfile(mockProfile); + mockGetProfileGrants(mockGrants); + mockGetUser(mockUser); + + cy.visitWithLogin('/images/create/upload'); + + // Confirm that a notice should be shown informing the user they do not have permission to create a Linode. + cy.findByText( + "You don't have permissions to create Images. Please contact your account administrator to request the necessary permissions." + ).should('be.visible'); + + // Check the following fields are disabled + + // Confirm that "Label" field is diabled + cy.get('[id="label"]').should('be.visible').should('be.disabled'); + + // Confirm that "Cloud init compatibility checkbox" field is diabled + cy.get('[type="checkbox"]').should('be.disabled'); + + // Confirm that "Region" field is diabled + cy.get('[data-qa-autocomplete="Region"]').within(() => { + cy.get('[title="Open"]').should('be.visible').should('be.disabled'); + }); + + // Confirm that "Add Tags" field is disabled + cy.get('[data-qa-autocomplete="Add Tags"]').within(() => { + cy.get('[title="Open"]').should('be.visible').should('be.disabled'); + }); + + // Confirm that "Description" field is disabled + cy.get('[id="description"]').should('be.visible').should('be.disabled'); + + // Confirm that "Choose File" button is disabled + ui.button + .findByTitle('Choose File') + .should('be.visible') + .should('be.disabled'); + + // Confirm that "Upload Using Command Line" button is disabled + ui.button + .findByTitle('Upload Using Command Line') + .should('be.visible') + .should('be.disabled'); + + // Confirm that "Upload Image" button is disabled + cy.get('[type="submit"]').should('be.visible').should('be.disabled'); + }); }); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts index e44b08523a2..5a3b3490b3e 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -1,64 +1,69 @@ /** * @file LKE creation end-to-end tests. */ +import { pluralize } from '@linode/utilities'; import { - accountFactory, - dedicatedTypeFactory, - kubernetesClusterFactory, - kubernetesControlPlaneACLFactory, - kubernetesControlPlaneACLOptionsFactory, - linodeTypeFactory, - regionFactory, - nodePoolFactory, - kubeLinodeFactory, - lkeHighAvailabilityTypeFactory, -} from 'src/factories'; + dcPricingDocsLabel, + dcPricingDocsUrl, + dcPricingLkeCheckoutSummaryPlaceholder, + dcPricingLkeClusterPlans, + dcPricingLkeHAPlaceholder, + dcPricingMockLinodeTypes, + dcPricingPlanPlaceholder, +} from 'support/constants/dc-specific-pricing'; +import { + latestEnterpriseTierKubernetesVersion, + latestKubernetesVersion, +} from 'support/constants/lke'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetAccountBeta } from 'support/intercepts/betas'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetLinodeTypes } from 'support/intercepts/linodes'; import { mockCreateCluster, - mockGetCluster, mockCreateClusterError, - mockGetControlPlaneACL, - mockGetClusterPools, - mockGetDashboardUrl, mockGetApiEndpoints, + mockGetCluster, + mockGetClusterPools, mockGetClusters, + mockGetControlPlaneACL, + mockGetDashboardUrl, + mockGetKubernetesVersions, mockGetLKEClusterTypes, mockGetTieredKubernetesVersions, - mockGetKubernetesVersions, } from 'support/intercepts/lke'; -import { mockGetAccountBeta } from 'support/intercepts/betas'; -import { mockGetAccount } from 'support/intercepts/account'; import { - mockGetRegions, mockGetRegionAvailability, + mockGetRegions, } from 'support/intercepts/regions'; -import { getRegionById } from 'support/util/regions'; import { ui } from 'support/ui'; -import { randomLabel, randomNumber, randomItem } from 'support/util/random'; -import { - dcPricingLkeCheckoutSummaryPlaceholder, - dcPricingLkeHAPlaceholder, - dcPricingLkeClusterPlans, - dcPricingMockLinodeTypes, - dcPricingPlanPlaceholder, - dcPricingDocsLabel, - dcPricingDocsUrl, -} from 'support/constants/dc-specific-pricing'; -import { mockGetLinodeTypes } from 'support/intercepts/linodes'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { randomItem, randomLabel, randomNumber } from 'support/util/random'; +import { getRegionById } from 'support/util/regions'; import { chooseRegion } from 'support/util/regions'; + +import { accountBetaFactory, lkeEnterpriseTypeFactory } from 'src/factories'; +import { + accountFactory, + dedicatedTypeFactory, + kubeLinodeFactory, + kubernetesClusterFactory, + kubernetesControlPlaneACLFactory, + kubernetesControlPlaneACLOptionsFactory, + linodeTypeFactory, + lkeHighAvailabilityTypeFactory, + nodePoolFactory, + regionFactory, +} from 'src/factories'; +import { + CLUSTER_TIER_DOCS_LINK, + CLUSTER_VERSIONS_DOCS_LINK, +} from 'src/features/Kubernetes/constants'; import { getTotalClusterMemoryCPUAndStorage } from 'src/features/Kubernetes/kubeUtils'; import { getTotalClusterPrice } from 'src/utilities/pricing/kubernetes'; +import type { PriceType } from '@linode/api-v4/lib/types'; import type { ExtendedType } from 'src/utilities/extendType'; import type { LkePlanDescription } from 'support/api/lke'; -import { PriceType } from '@linode/api-v4/lib/types'; -import { - latestEnterpriseTierKubernetesVersion, - latestKubernetesVersion, -} from 'support/constants/lke'; -import { lkeEnterpriseTypeFactory } from 'src/factories'; -import { pluralize } from 'src/utilities/pluralize'; const dedicatedNodeCount = 4; const nanodeNodeCount = 3; @@ -104,6 +109,18 @@ const nanodeType = linodeTypeFactory.build({ )?.region_prices, vcpus: 1, }) as ExtendedType; +const gpuType = linodeTypeFactory.build({ + class: 'gpu', + id: 'g2-gpu-1', +}) as ExtendedType; +const highMemType = linodeTypeFactory.build({ + class: 'highmem', + id: 'g7-highmem-1', +}) as ExtendedType; +const premiumType = linodeTypeFactory.build({ + class: 'premium', + id: 'g7-premium-1', +}) as ExtendedType; const mockedLKEClusterPrices: PriceType[] = [ { id: 'lke-sa', @@ -148,7 +165,20 @@ const clusterPlans: LkePlanDescription[] = [ type: 'nanode', }, ]; -const mockedLKEClusterTypes = [dedicatedType, nanodeType]; +const mockedLKEClusterTypes = [ + dedicatedType, + nanodeType, + gpuType, + highMemType, + premiumType, +]; +const validEnterprisePlanTabs = [ + 'Dedicated CPU', + 'Shared CPU', + 'High Memory', + 'Premium CPU', +]; +const validStandardPlanTabs = [...validEnterprisePlanTabs, 'GPU']; describe('LKE Cluster Creation', () => { /* @@ -220,6 +250,11 @@ describe('LKE Cluster Creation', () => { let monthPrice = 0; + // Confirm the expected available plans display. + validStandardPlanTabs.forEach((tab) => { + ui.tabList.findTabByTitle(tab).should('be.visible'); + }); + // Add a node pool for each selected plan, and confirm that the // selected node pool plan is added to the checkout bar. clusterPlans.forEach((clusterPlan) => { @@ -371,7 +406,8 @@ describe('LKE Cluster Creation with APL enabled', () => { )?.region_prices, vcpus: 8, }); - const mockedLKEClusterTypes = [ + + const mockedAPLLKEClusterTypes = [ dedicatedType, dedicated4Type, dedicated8Type, @@ -383,13 +419,13 @@ describe('LKE Cluster Creation with APL enabled', () => { }, }).as('getFeatureFlags'); mockGetAccountBeta({ - id: 'apl', - label: 'Akamai App Platform Beta', - enrolled: '2024-11-04T21:39:41', description: 'Akamai App Platform is a platform that combines developer and operations-centric tools, automation and self-service to streamline the application lifecycle when using Kubernetes. This process will pre-register you for an upcoming beta.', - started: '2024-10-31T18:00:00', ended: null, + enrolled: '2024-11-04T21:39:41', + id: 'apl', + label: 'Akamai App Platform Beta', + started: '2024-10-31T18:00:00', }).as('getAccountBeta'); mockCreateCluster(mockedLKECluster).as('createCluster'); mockGetCluster(mockedLKECluster).as('getCluster'); @@ -401,7 +437,7 @@ describe('LKE Cluster Creation with APL enabled', () => { mockedLKECluster.id, mockedLKEClusterControlPlane ).as('getControlPlaneACL'); - mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); + mockGetLinodeTypes(mockedAPLLKEClusterTypes).as('getLinodeTypes'); mockGetLKEClusterTypes(mockedLKEHAClusterPrices).as('getLKEClusterTypes'); mockGetApiEndpoints(mockedLKECluster.id).as('getApiEndpoints'); @@ -423,6 +459,7 @@ describe('LKE Cluster Creation with APL enabled', () => { ui.regionSelect.find().click().type(`${clusterRegion.label}{enter}`); cy.findByTestId('apl-label').should('have.text', 'Akamai App Platform'); + cy.findByTestId('apl-beta-chip').should('have.text', 'BETA'); cy.findByTestId('apl-radio-button-yes').should('be.visible').click(); cy.findByTestId('ha-radio-button-yes').should('be.disabled'); cy.get( @@ -536,15 +573,11 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { .should('have.attr', 'href', dcPricingDocsUrl); // Fill out LKE creation form label, region, and Kubernetes version fields. - cy.findByLabelText('Cluster Label') - .should('be.visible') - .click() - .type(`${clusterLabel}{enter}`); + cy.findByLabelText('Cluster Label').should('be.visible').click(); + cy.focused().type(`${clusterLabel}{enter}`); - ui.regionSelect - .find() - .click() - .type(`${dcSpecificPricingRegion.label}{enter}`); + ui.regionSelect.find().click(); + cy.focused().type(`${dcSpecificPricingRegion.label}{enter}`); // Confirm that HA price updates dynamically once region selection is made. cy.contains(/\$.*\/month/).should('be.visible'); @@ -570,10 +603,8 @@ describe('LKE Cluster Creation with DC-specific pricing', () => { .should('be.visible') .closest('tr') .within(() => { - cy.get('[name="Quantity"]') - .should('be.visible') - .click() - .type(`{selectall}${nodeCount}`); + cy.get('[name="Quantity"]').should('be.visible').click(); + cy.focused().type(`{selectall}${nodeCount}`); ui.button .findByTitle('Add') @@ -639,14 +670,14 @@ describe('LKE Cluster Creation with ACL', () => { }); const mockLinodeTypes = [ linodeTypeFactory.build({ + class: 'dedicated', id: 'dedicated-1', label: 'dedicated-1', - class: 'dedicated', }), linodeTypeFactory.build({ + class: 'dedicated', id: 'dedicated-2', label: 'dedicated-2', - class: 'dedicated', }), ]; const clusterVersion = '1.31'; @@ -682,10 +713,10 @@ describe('LKE Cluster Creation with ACL', () => { }, }); const mockCluster = kubernetesClusterFactory.build({ + control_plane: mockACL, + k8s_version: clusterVersion, label: clusterLabel, region: mockRegion.id, - k8s_version: clusterVersion, - control_plane: mockACL, }); mockCreateCluster(mockCluster).as('createCluster'); mockGetCluster(mockCluster).as('getCluster'); @@ -703,18 +734,14 @@ describe('LKE Cluster Creation with ACL', () => { cy.wait(['@getAccount', '@getRegions', '@getLinodeTypes']); // Fill out LKE creation form label, region, and Kubernetes version fields. - cy.findByLabelText('Cluster Label') - .should('be.visible') - .click() - .type(`${clusterLabel}{enter}`); + cy.findByLabelText('Cluster Label').should('be.visible').click(); + cy.focused().type(`${clusterLabel}{enter}`); ui.regionSelect.find().click().type(`${mockRegion.label}{enter}`); cy.wait(['@getRegionAvailability']); - cy.findByText('Kubernetes Version') - .should('be.visible') - .click() - .type(`${clusterVersion}{enter}`); + cy.findByText('Kubernetes Version').should('be.visible').click(); + cy.focused().type(`${clusterVersion}{enter}`); cy.get('[data-testid="ha-radio-button-yes"]') .should('be.visible') @@ -733,10 +760,8 @@ describe('LKE Cluster Creation with ACL', () => { .should('be.visible') .closest('tr') .within(() => { - cy.get('[name="Quantity"]') - .should('be.visible') - .click() - .type(`{selectall}${nodeCount}`); + cy.get('[name="Quantity"]').should('be.visible').click(); + cy.focused().type(`{selectall}${nodeCount}`); ui.button .findByTitle('Add') @@ -792,10 +817,10 @@ describe('LKE Cluster Creation with ACL', () => { }); const mockCluster = kubernetesClusterFactory.build({ + control_plane: mockACL, + k8s_version: clusterVersion, label: clusterLabel, region: mockRegion.id, - k8s_version: clusterVersion, - control_plane: mockACL, }); mockCreateCluster(mockCluster).as('createCluster'); mockGetCluster(mockCluster).as('getCluster'); @@ -813,17 +838,13 @@ describe('LKE Cluster Creation with ACL', () => { cy.wait(['@getAccount']); // Fill out LKE creation form label, region, and Kubernetes version fields. - cy.findByLabelText('Cluster Label') - .should('be.visible') - .click() - .type(`${clusterLabel}{enter}`); + cy.findByLabelText('Cluster Label').should('be.visible').click(); + cy.focused().type(`${clusterLabel}{enter}`); ui.regionSelect.find().click().type(`${mockRegion.label}{enter}`); - cy.findByText('Kubernetes Version') - .should('be.visible') - .click() - .type(`${clusterVersion}{enter}`); + cy.findByText('Kubernetes Version').should('be.visible').click(); + cy.focused().type(`${clusterVersion}{enter}`); cy.get('[data-testid="ha-radio-button-yes"]') .should('be.visible') @@ -842,20 +863,18 @@ describe('LKE Cluster Creation with ACL', () => { // Add some IPv4s and an IPv6 cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') .should('be.visible') - .click() - .type('10.0.0.0/24'); + .click(); + cy.focused().type('10.0.0.0/24'); cy.findByText('Add IPv4 Address') .should('be.visible') .should('be.enabled') .click(); - cy.get('[id="domain-transfer-ip-1"]') - .should('be.visible') - .click() - .type('10.0.1.0/24'); + cy.get('[id="domain-transfer-ip-1"]').should('be.visible').click(); + cy.focused().type('10.0.1.0/24'); cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') .should('be.visible') - .click() - .type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); + .click(); + cy.focused().type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); cy.findByText('Add IPv6 Address') .should('be.visible') .should('be.enabled') @@ -867,10 +886,8 @@ describe('LKE Cluster Creation with ACL', () => { .should('be.visible') .closest('tr') .within(() => { - cy.get('[name="Quantity"]') - .should('be.visible') - .click() - .type(`{selectall}${nodeCount}`); + cy.get('[name="Quantity"]').should('be.visible').click(); + cy.focused().type(`{selectall}${nodeCount}`); ui.button .findByTitle('Add') @@ -914,6 +931,223 @@ describe('LKE Cluster Creation with ACL', () => { .should('be.enabled'); }); + /** + * - Confirms create flow for LKE-E cluster with ACL enabled by default + * - Confirms at least one IP must be provided for ACL unless acknowledgement is checked + * - Confirms the cluster details page shows ACL is enabled + */ + it('creates an LKE cluster with ACL enabled by default and handles IP address validation', () => { + const clusterLabel = randomLabel(); + const mockedEnterpriseCluster = kubernetesClusterFactory.build({ + k8s_version: latestEnterpriseTierKubernetesVersion.id, + label: clusterLabel, + region: 'us-iad', + tier: 'enterprise', + }); + const mockedEnterpriseClusterPools = [nanodeMemoryPool]; + const mockACL = kubernetesControlPlaneACLFactory.build({ + acl: { + addresses: { + ipv4: [], + ipv6: [], + }, + enabled: true, + 'revision-id': '', + }, + }); + mockGetControlPlaneACL(mockedEnterpriseCluster.id, mockACL).as( + 'getControlPlaneACL' + ); + mockGetAccount( + accountFactory.build({ + capabilities: [ + 'Kubernetes Enterprise', + 'LKE HA Control Planes', + 'LKE Network Access Control List (IP ACL)', + ], + }) + ).as('getAccount'); + mockGetTieredKubernetesVersions('enterprise', [ + latestEnterpriseTierKubernetesVersion, + ]).as('getTieredKubernetesVersions'); + mockGetKubernetesVersions([latestKubernetesVersion]).as( + 'getKubernetesVersions' + ); + mockGetLinodeTypes(mockedLKEClusterTypes).as('getLinodeTypes'); + mockGetLKEClusterTypes(mockedLKEEnterprisePrices).as( + 'getLKEEnterpriseClusterTypes' + ); + mockGetRegions([ + regionFactory.build({ + capabilities: ['Linodes', 'Kubernetes', 'Kubernetes Enterprise'], + id: 'us-iad', + label: 'Washington, DC', + }), + ]).as('getRegions'); + mockGetCluster(mockedEnterpriseCluster).as('getCluster'); + mockCreateCluster(mockedEnterpriseCluster).as('createCluster'); + mockGetClusters([mockedEnterpriseCluster]).as('getClusters'); + mockGetClusterPools( + mockedEnterpriseCluster.id, + mockedEnterpriseClusterPools + ).as('getClusterPools'); + mockGetDashboardUrl(mockedEnterpriseCluster.id).as('getDashboardUrl'); + mockGetApiEndpoints(mockedEnterpriseCluster.id).as('getApiEndpoints'); + + cy.visitWithLogin('/kubernetes/clusters'); + cy.wait(['@getAccount']); + + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + + cy.url().should('endWith', '/kubernetes/create'); + cy.wait(['@getKubernetesVersions', '@getTieredKubernetesVersions']); + + // Select enterprise tier. + cy.get(`[data-qa-select-card-heading="LKE Enterprise"]`) + .closest('[data-qa-selection-card]') + .click(); + + cy.wait(['@getLKEEnterpriseClusterTypes', '@getRegions']); + + // Select a supported region. + ui.regionSelect.find().clear().type('Washington, DC{enter}'); + + // Select an enterprise version. + ui.autocomplete + .findByLabel('Kubernetes Version') + .should('be.visible') + .click(); + + clusterPlans.forEach((clusterPlan) => { + const nodeCount = clusterPlan.nodeCount; + const planName = clusterPlan.planName; + // Click the right tab for the plan, and add a node pool with the desired + // number of nodes. + cy.findByText(clusterPlan.tab).should('be.visible').click(); + const quantityInput = '[name="Quantity"]'; + cy.findByText(planName) + .should('be.visible') + .closest('tr') + .within(() => { + cy.get(quantityInput).should('be.visible'); + cy.get(quantityInput).click(); + cy.get(quantityInput).type(`{selectall}${nodeCount}`); + + ui.button + .findByTitle('Add') + .should('be.visible') + .should('be.enabled') + .click(); + }); + }); + + // Confirm ACL is enabled by default. + cy.contains('Control Plane ACL').should('be.visible'); + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'true') + .should('be.visible'); + cy.findByRole('checkbox', { name: /Provide an ACL later/ }).should( + 'not.be.checked' + ); + + // Try to submit the form without the ACL acknowledgement checked. + cy.get('[data-testid="kube-checkout-bar"]') + .should('be.visible') + .within(() => { + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm error validation requires an ACL IP. + cy.findByText( + 'At least one IP address or CIDR range is required for LKE Enterprise.' + ).should('be.visible'); + + // Add an IP, + cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') + .should('be.visible') + .click(); + cy.focused().clear(); + cy.focused().type('10.0.0.0/24'); + + cy.get('[data-testid="kube-checkout-bar"]') + .should('be.visible') + .within(() => { + // Try to submit the form again. + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm the validation message is gone. + cy.findByText( + 'At least one IP address or CIDR range is required for LKE Enterprise.' + ).should('not.exist'); + + // Check the acknowledgement to prevent IP validation. + cy.findByRole('checkbox', { name: /Provide an ACL later/ }).check(); + + // Clear the IP address field and check the acknowledgement to confirm the form can now submit without IP address validation. + cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') + .should('be.visible') + .click(); + cy.focused().clear(); + cy.findByRole('checkbox', { name: /Provide an ACL later/ }).check(); + + // Finally, add a label, so the form will submit. + cy.findByLabelText('Cluster Label').should('be.visible').click(); + cy.focused().type(`${clusterLabel}{enter}`); + + cy.get('[data-testid="kube-checkout-bar"]') + .should('be.visible') + .within(() => { + // Try to submit the form. + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + // Confirm the validation message is gone. + cy.findByText( + 'At least one IP address or CIDR range is required for LKE Enterprise.' + ).should('not.exist'); + + cy.wait([ + '@getCluster', + '@getClusterPools', + '@createCluster', + '@getLKEEnterpriseClusterTypes', + '@getLinodeTypes', + '@getDashboardUrl', + '@getApiEndpoints', + '@getControlPlaneACL', + ]); + + cy.url().should( + 'endWith', + `/kubernetes/clusters/${mockedEnterpriseCluster.id}/summary` + ); + + // Confirms Summary panel displays as expected + cy.contains('Control Plane ACL').should('be.visible'); + ui.button + .findByTitle('Enabled (0 IP Addresses)') + .should('be.visible') + .should('be.enabled'); + }); + /** * - Confirms IP validation error appears when a bad IP is entered * - Confirms IP validation error disappears when a valid IP is entered @@ -935,17 +1169,13 @@ describe('LKE Cluster Creation with ACL', () => { cy.wait(['@getAccount']); // Fill out LKE creation form label, region, and Kubernetes version fields. - cy.findByLabelText('Cluster Label') - .should('be.visible') - .click() - .type(`${clusterLabel}{enter}`); + cy.findByLabelText('Cluster Label').should('be.visible').click(); + cy.focused().type(`${clusterLabel}{enter}`); ui.regionSelect.find().click().type(`${mockRegion.label}{enter}`); - cy.findByText('Kubernetes Version') - .should('be.visible') - .click() - .type(`${clusterVersion}{enter}`); + cy.findByText('Kubernetes Version').should('be.visible').click(); + cy.focused().type(`${clusterVersion}{enter}`); cy.get('[data-testid="ha-radio-button-yes"]') .should('be.visible') @@ -964,8 +1194,8 @@ describe('LKE Cluster Creation with ACL', () => { // Confirm ACL IPv4 validation works as expected cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') .should('be.visible') - .click() - .type('invalid ip'); + .click(); + cy.focused().type('invalid ip'); // click out of textbox and confirm error is visible cy.contains('Control Plane ACL').should('be.visible').click(); @@ -973,9 +1203,9 @@ describe('LKE Cluster Creation with ACL', () => { // enter valid IP cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') .should('be.visible') - .click() - .clear() - .type('10.0.0.0/24'); + .click(); + cy.focused().clear(); + cy.focused().type('10.0.0.0/24'); // Click out of textbox and confirm error is gone cy.contains('Control Plane ACL').should('be.visible').click(); cy.contains('Must be a valid IPv4 address.').should('not.exist'); @@ -983,17 +1213,17 @@ describe('LKE Cluster Creation with ACL', () => { // Confirm ACL IPv6 validation works as expected cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') .should('be.visible') - .click() - .type('invalid ip'); + .click(); + cy.focused().type('invalid ip'); // click out of textbox and confirm error is visible cy.contains('Control Plane ACL').should('be.visible').click(); cy.contains('Must be a valid IPv6 address.').should('be.visible'); // enter valid IP cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') .should('be.visible') - .click() - .clear() - .type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); + .click(); + cy.focused().clear(); + cy.focused().type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); // Click out of textbox and confirm error is gone cy.contains('Control Plane ACL').should('be.visible').click(); cy.contains('Must be a valid IPv6 address.').should('not.exist'); @@ -1004,10 +1234,8 @@ describe('LKE Cluster Creation with ACL', () => { .should('be.visible') .closest('tr') .within(() => { - cy.get('[name="Quantity"]') - .should('be.visible') - .click() - .type(`{selectall}${nodeCount}`); + cy.get('[name="Quantity"]').should('be.visible').click(); + cy.focused().type(`{selectall}${nodeCount}`); ui.button .findByTitle('Add') @@ -1077,6 +1305,8 @@ describe('LKE Cluster Creation with LKE-E', () => { * - Confirms that HA is enabled by default with LKE-E selection * - Confirms an LKE-E supported region can be selected * - Confirms an LKE-E supported k8 version can be selected + * - Confirms the APL section is disabled while it remains unsupported + * - Confirms ACL is enabled by default * - Confirms the checkout bar displays the correct LKE-E info * - Confirms an enterprise cluster can be created with the correct chip, version, and price * - Confirms that the total node count for each pool is displayed @@ -1084,17 +1314,39 @@ describe('LKE Cluster Creation with LKE-E', () => { it('creates an LKE-E cluster with the account capability', () => { const clusterLabel = randomLabel(); const mockedEnterpriseCluster = kubernetesClusterFactory.build({ + k8s_version: latestEnterpriseTierKubernetesVersion.id, label: clusterLabel, region: 'us-iad', tier: 'enterprise', - k8s_version: latestEnterpriseTierKubernetesVersion.id, }); const mockedEnterpriseClusterPools = [nanodeMemoryPool, dedicatedCpuPool]; - const mockedLKEClusterTypes = [dedicatedType, nanodeType]; + const mockACL = kubernetesControlPlaneACLFactory.build({ + acl: { + addresses: { + ipv4: ['10.0.0.0/24'], + ipv6: [], + }, + enabled: true, + 'revision-id': '', + }, + }); + mockGetControlPlaneACL(mockedEnterpriseCluster.id, mockACL).as( + 'getControlPlaneACL' + ); + mockGetAccountBeta( + accountBetaFactory.build({ + id: 'apl', + label: 'Akamai App Platform Beta', + }) + ).as('getAccountBeta'); mockGetAccount( accountFactory.build({ - capabilities: ['Kubernetes Enterprise'], + capabilities: [ + 'Kubernetes Enterprise', + 'LKE HA Control Planes', + 'LKE Network Access Control List (IP ACL)', + ], }) ).as('getAccount'); mockGetTieredKubernetesVersions('enterprise', [ @@ -1141,13 +1393,15 @@ describe('LKE Cluster Creation with LKE-E', () => { cy.url().should('endWith', '/kubernetes/create'); cy.wait(['@getKubernetesVersions', '@getTieredKubernetesVersions']); - cy.findByLabelText('Cluster Label') - .should('be.visible') - .click() - .type(`${clusterLabel}{enter}`); + cy.findByLabelText('Cluster Label').should('be.visible').click(); + cy.focused().type(`${clusterLabel}{enter}`); cy.findByText('Cluster Tier').should('be.visible'); + cy.findByText('Compare Tiers') + .should('be.visible') + .should('have.attr', 'href', CLUSTER_TIER_DOCS_LINK); + // Confirm both Cluster Tiers exist and the LKE card is selected by default cy.get(`[data-qa-select-card-heading="LKE"]`) .closest('[data-qa-selection-card]') @@ -1180,7 +1434,7 @@ describe('LKE Cluster Creation with LKE-E', () => { // Confirm that there is a tooltip explanation for the region dropdown options ui.tooltip .findByText( - 'Only regions that support Kubernetes Enterprise are listed.' + 'Only regions that support LKE Enterprise clusters are listed.' ) .should('be.visible'); @@ -1190,12 +1444,34 @@ describe('LKE Cluster Creation with LKE-E', () => { .should('be.visible') .click(); + cy.findByText('Kubernetes Versions') + .should('be.visible') + .should('have.attr', 'href', CLUSTER_VERSIONS_DOCS_LINK); + ui.autocompletePopper .findByTitle(latestEnterpriseTierKubernetesVersion.id) .should('be.visible') .should('be.enabled') .click(); + // Confirm the APL section is disabled and unsupported. + cy.findByTestId('apl-label').should('be.visible'); + cy.findByTestId('apl-beta-chip').should( + 'have.text', + 'BETA - COMING SOON' + ); + cy.findByTestId('apl-radio-button-yes').should('be.disabled'); + cy.findByTestId('apl-radio-button-no').within(() => { + cy.findByRole('radio').should('be.disabled').should('be.checked'); + }); + + // Confirm the expected available plans display. + validEnterprisePlanTabs.forEach((tab) => { + ui.tabList.findTabByTitle(tab).should('be.visible'); + }); + // Confirm the GPU tab is not visible in the plans panel for LKE-E. + ui.tabList.findTabByTitle('GPU').should('not.exist'); + // Add a node pool for each selected plan, and confirm that the // selected node pool plan is added to the checkout bar. clusterPlans.forEach((clusterPlan) => { @@ -1234,17 +1510,33 @@ describe('LKE Cluster Creation with LKE-E', () => { // Confirm LKE-E section is shown cy.findByText('LKE Enterprise').should('be.visible'); - cy.findByText('HA control plane, Dedicated control plane').should( - 'be.visible' - ); cy.findByText('$300.00/month').should('be.visible'); - cy.findByText(`Dedicated 4 GB Plan`).should('be.visible'); + cy.findByText('Dedicated 4 GB Plan').should('be.visible'); cy.findByText('$144.00').should('be.visible'); - cy.findByText(`Linode 2 GB Plan`).should('be.visible'); + cy.findByText('Linode 2 GB Plan').should('be.visible'); cy.findByText('$15.00').should('be.visible'); cy.findByText('$459.00').should('be.visible'); + }); + + // Confirms ACL is enabled by default. + cy.contains('Control Plane ACL').should('be.visible'); + ui.toggle + .find() + .should('have.attr', 'data-qa-toggle', 'true') + .should('be.visible'); + + // Add an IP + cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') + .should('be.visible') + .click(); + cy.focused().clear(); + cy.focused().type('10.0.0.0/24'); + cy.get('[data-testid="kube-checkout-bar"]') + .should('be.visible') + .within(() => { + // Successfully submit the form ui.button .findByTitle('Create Cluster') .should('be.visible') @@ -1262,6 +1554,7 @@ describe('LKE Cluster Creation with LKE-E', () => { '@getLinodeTypes', '@getDashboardUrl', '@getApiEndpoints', + '@getControlPlaneACL', ]); cy.url().should( diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-delete.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-delete.spec.ts index 9aaa064782d..eabaacae666 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-delete.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-delete.spec.ts @@ -1,12 +1,13 @@ -import { kubernetesClusterFactory } from 'src/factories'; import { + mockDeleteCluster, mockGetCluster, mockGetClusters, - mockDeleteCluster, } from 'support/intercepts/lke'; import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; +import { kubernetesClusterFactory } from 'src/factories'; + /* * Fills out and submits Type to Confirm deletion dialog for cluster with the given label. */ @@ -18,10 +19,8 @@ const completeTypeToConfirmDialog = (clusterLabel: string) => { .should('be.visible') .within(() => { cy.findByText(deletionWarning, { exact: false }).should('be.visible'); - cy.findByLabelText('Cluster Name') - .should('be.visible') - .click() - .type(clusterLabel); + cy.findByLabelText('Cluster Name').should('be.visible').click(); + cy.focused().type(clusterLabel); ui.buttonGroup .findButtonByTitle('Delete Cluster') diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts index e4eaa8c155b..96fb31a0c8a 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-landing-page.spec.ts @@ -1,23 +1,25 @@ -import type { KubernetesCluster } from '@linode/api-v4'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { - mockGetClusters, mockGetClusterPools, + mockGetClusters, mockGetKubeconfig, mockGetKubernetesVersions, mockGetTieredKubernetesVersions, mockRecycleAllNodes, mockUpdateCluster, } from 'support/intercepts/lke'; +import { ui } from 'support/ui'; +import { readDownload } from 'support/util/downloads'; +import { getRegionById } from 'support/util/regions'; + import { accountFactory, kubernetesClusterFactory, nodePoolFactory, } from 'src/factories'; -import { getRegionById } from 'support/util/regions'; -import { readDownload } from 'support/util/downloads'; -import { ui } from 'support/ui'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { mockGetAccount } from 'support/intercepts/account'; + +import type { KubernetesCluster } from '@linode/api-v4'; describe('LKE landing page', () => { it('does not display a Disk Encryption info banner if the LDE feature is disabled', () => { @@ -250,7 +252,7 @@ describe('LKE landing page', () => { ui.dialog .findByTitle( - `Step 1: Upgrade ${cluster.label} to Kubernetes ${newVersion}` + `Upgrade Kubernetes version to ${newVersion} on ${cluster.label}?` ) .should('be.visible'); @@ -264,9 +266,7 @@ describe('LKE landing page', () => { cy.wait(['@updateCluster', '@getClusters']); - ui.dialog - .findByTitle('Step 2: Recycle All Cluster Nodes') - .should('be.visible'); + ui.dialog.findByTitle('Upgrade complete').should('be.visible'); ui.button .findByTitle('Recycle All Nodes') @@ -321,7 +321,7 @@ describe('LKE landing page', () => { ui.dialog .findByTitle( - `Step 1: Upgrade ${cluster.label} to Kubernetes ${newVersion}` + `Upgrade Kubernetes version to ${newVersion} on ${cluster.label}?` ) .should('be.visible'); @@ -335,9 +335,7 @@ describe('LKE landing page', () => { cy.wait(['@updateCluster', '@getClusters']); - ui.dialog - .findByTitle('Step 2: Recycle All Cluster Nodes') - .should('be.visible'); + ui.dialog.findByTitle('Upgrade complete').should('be.visible'); ui.button .findByTitle('Recycle All Nodes') diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-summary-page.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-summary-page.spec.ts index 18ea69b8634..2782745e969 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-summary-page.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-summary-page.spec.ts @@ -1,23 +1,24 @@ -import { - kubernetesClusterFactory, - kubernetesControlPlaneACLFactory, - nodePoolFactory, -} from 'src/factories'; import { latestKubernetesVersion } from 'support/constants/lke'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { + mockGetApiEndpoints, mockGetCluster, - mockGetKubeconfig, - mockGetKubernetesVersions, mockGetClusterPools, - mockGetDashboardUrl, - mockGetApiEndpoints, mockGetControlPlaneACL, + mockGetDashboardUrl, + mockGetKubeconfig, + mockGetKubernetesVersions, mockUpdateCluster, } from 'support/intercepts/lke'; -import { randomLabel } from 'support/util/random'; -import { readDownload } from 'support/util/downloads'; import { ui } from 'support/ui'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { readDownload } from 'support/util/downloads'; +import { randomLabel } from 'support/util/random'; + +import { + kubernetesClusterFactory, + kubernetesControlPlaneACLFactory, + nodePoolFactory, +} from 'src/factories'; const mockKubeconfigContents = '---'; // Valid YAML. const mockKubeconfigResponse = { @@ -88,8 +89,8 @@ describe('LKE summary page', () => { }, }); const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, control_plane: mockACL, + k8s_version: latestKubernetesVersion, }); const tag = randomLabel(); const mockClusterUpdated = { @@ -120,8 +121,8 @@ describe('LKE summary page', () => { }); cy.get('[data-qa-autocomplete="Create or Select a Tag"]') .should('be.visible') - .clear() - .type(`${tag}`); + .clear(); + cy.focused().type(`${tag}`); cy.findByText(`Create "${tag}"`).should('be.visible').click(); // Confirms that a put request is sent @@ -150,8 +151,8 @@ describe('LKE summary page', () => { const tagNew = randomLabel(); const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, control_plane: mockACL, + k8s_version: latestKubernetesVersion, tags: tagsExisting, }); @@ -191,8 +192,8 @@ describe('LKE summary page', () => { cy.findByText('Add a tag').click(); cy.get('[data-qa-autocomplete="Create or Select a Tag"]') .should('be.visible') - .clear() - .type(`${tagNew}`); + .clear(); + cy.focused().type(`${tagNew}`); cy.findByText(`Create "${tagNew}"`).should('be.visible').click(); // Confirms that a put request is sent @@ -220,8 +221,8 @@ describe('LKE summary page', () => { }); const tagExisting = randomLabel(); const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, control_plane: mockACL, + k8s_version: latestKubernetesVersion, tags: [tagExisting], }); @@ -273,8 +274,8 @@ describe('LKE summary page', () => { }); const tagsExisting = buildTags(2); const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, control_plane: mockACL, + k8s_version: latestKubernetesVersion, tags: tagsExisting, }); diff --git a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts index 7887107e5f5..0f87c101f01 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -1,51 +1,54 @@ -import { - accountFactory, - kubernetesClusterFactory, - nodePoolFactory, - kubeLinodeFactory, - linodeFactory, - kubernetesControlPlaneACLFactory, - kubernetesControlPlaneACLOptionsFactory, - linodeTypeFactory, -} from 'src/factories'; -import { extendType } from 'src/utilities/extendType'; -import { mockGetAccount } from 'support/intercepts/account'; +import { DateTime } from 'luxon'; +import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; import { latestKubernetesVersion } from 'support/constants/lke'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetLinodeType, + mockGetLinodeTypes, + mockGetLinodes, +} from 'support/intercepts/linodes'; import { - mockGetCluster, - mockGetKubernetesVersions, - mockGetClusterPools, - mockResetKubeconfig, - mockUpdateCluster, mockAddNodePool, - mockUpdateNodePool, mockDeleteNodePool, - mockRecycleNode, - mockRecycleNodePool, - mockRecycleAllNodes, - mockGetDashboardUrl, mockGetApiEndpoints, - mockUpdateControlPlaneACL, + mockGetCluster, + mockGetClusterPools, mockGetControlPlaneACL, - mockUpdateControlPlaneACLError, mockGetControlPlaneACLError, + mockGetDashboardUrl, + mockGetKubernetesVersions, mockGetTieredKubernetesVersions, + mockRecycleAllNodes, + mockRecycleNode, + mockRecycleNodePool, + mockResetKubeconfig, + mockUpdateCluster, mockUpdateClusterError, + mockUpdateControlPlaneACL, + mockUpdateControlPlaneACLError, + mockUpdateNodePool, mockUpdateNodePoolError, } from 'support/intercepts/lke'; -import { - mockGetLinodeType, - mockGetLinodeTypes, - mockGetLinodes, -} from 'support/intercepts/linodes'; -import type { PoolNodeResponse, Linode, Taint, Label } from '@linode/api-v4'; import { ui } from 'support/ui'; +import { buildArray } from 'support/util/arrays'; +import { randomString } from 'support/util/random'; import { randomIp, randomLabel } from 'support/util/random'; import { getRegionById } from 'support/util/regions'; -import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { randomString } from 'support/util/random'; -import { buildArray } from 'support/util/arrays'; + +import { + accountFactory, + kubeLinodeFactory, + kubernetesClusterFactory, + kubernetesControlPlaneACLFactory, + kubernetesControlPlaneACLOptionsFactory, + linodeFactory, + linodeTypeFactory, + nodePoolFactory, +} from 'src/factories'; +import { extendType } from 'src/utilities/extendType'; + +import type { Label, Linode, PoolNodeResponse, Taint } from '@linode/api-v4'; const mockNodePools = nodePoolFactory.buildList(2); @@ -66,10 +69,10 @@ describe('LKE cluster updates', () => { */ it('can upgrade to high availability', () => { const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, control_plane: { high_availability: false, }, + k8s_version: latestKubernetesVersion, }); const mockClusterWithHA = { @@ -81,7 +84,7 @@ describe('LKE cluster updates', () => { const haUpgradeWarnings = [ 'All nodes will be deleted and new nodes will be created to replace them.', - 'Any local storage (such as ’hostPath’ volumes) will be erased.', + 'Any data stored within local storage of your node(s) (such as ’hostPath’ volumes) is deleted.', 'This may take several minutes, as nodes will be replaced on a rolling basis.', ]; @@ -153,7 +156,7 @@ describe('LKE cluster updates', () => { const upgradePrompt = 'A new version of Kubernetes is available (1.26).'; const upgradeNotes = [ - 'Once the upgrade is complete you will need to recycle all nodes in your cluster', + 'This upgrades the control plane on your cluster and ensures that any new worker nodes are created using the newer Kubernetes version.', // Confirm that the old version and new version are both shown. oldVersion, newVersion, @@ -179,7 +182,7 @@ describe('LKE cluster updates', () => { ui.dialog .findByTitle( - `Step 1: Upgrade ${mockCluster.label} to Kubernetes ${newVersion}` + `Upgrade Kubernetes version to ${newVersion} on ${mockCluster.label}?` ) .should('be.visible') .within(() => { @@ -202,18 +205,21 @@ describe('LKE cluster updates', () => { mockRecycleAllNodes(mockCluster.id).as('recycleAllNodes'); - const stepTwoDialogTitle = 'Step 2: Recycle All Cluster Nodes'; + const stepTwoDialogTitle = 'Upgrade complete'; ui.dialog .findByTitle(stepTwoDialogTitle) .should('be.visible') .within(() => { - cy.findByText('Kubernetes version has been updated successfully.', { - exact: false, - }).should('be.visible'); + cy.findByText( + 'The cluster’s Kubernetes version has been updated successfully', + { + exact: false, + } + ).should('be.visible'); cy.findByText( - 'For the changes to take full effect you must recycle the nodes in your cluster.', + 'To upgrade your existing worker nodes, you can recycle all nodes (which may have a performance impact) or perform other upgrade methods.', { exact: false } ).should('be.visible'); @@ -273,7 +279,7 @@ describe('LKE cluster updates', () => { 'A new version of Kubernetes is available (1.31.1+lke2).'; const upgradeNotes = [ - 'Once the upgrade is complete you will need to recycle all nodes in your cluster', + 'This upgrades the control plane on your cluster and ensures that any new worker nodes are created using the newer Kubernetes version.', // Confirm that the old version and new version are both shown. oldVersion, newVersion, @@ -307,7 +313,7 @@ describe('LKE cluster updates', () => { ui.dialog .findByTitle( - `Step 1: Upgrade ${mockCluster.label} to Kubernetes ${newVersion}` + `Upgrade Kubernetes version to ${newVersion} on ${mockCluster.label}?` ) .should('be.visible') .within(() => { @@ -330,18 +336,21 @@ describe('LKE cluster updates', () => { mockRecycleAllNodes(mockCluster.id).as('recycleAllNodes'); - const stepTwoDialogTitle = 'Step 2: Recycle All Cluster Nodes'; + const stepTwoDialogTitle = 'Upgrade complete'; ui.dialog .findByTitle(stepTwoDialogTitle) .should('be.visible') .within(() => { - cy.findByText('Kubernetes version has been updated successfully.', { - exact: false, - }).should('be.visible'); + cy.findByText( + 'The cluster’s Kubernetes version has been updated successfully', + { + exact: false, + } + ).should('be.visible'); cy.findByText( - 'For the changes to take full effect you must recycle the nodes in your cluster.', + 'To upgrade your existing worker nodes, you can recycle all nodes (which may have a performance impact) or perform other upgrade methods.', { exact: false } ).should('be.visible'); @@ -380,20 +389,18 @@ describe('LKE cluster updates', () => { const mockNodePool = nodePoolFactory.build({ count: 1, - type: 'g6-standard-1', nodes: [mockKubeLinode], + type: 'g6-standard-1', }); const mockLinode = linodeFactory.build({ - label: randomLabel(), id: mockKubeLinode.instance_id ?? undefined, + label: randomLabel(), }); const recycleWarningSubstrings = [ - 'will be deleted', - 'will be created', - 'local storage (such as ’hostPath’ volumes) will be erased', - 'may take several minutes', + 'Any data stored within local storage of your node(s) (such as ’hostPath’ volumes) is deleted', + 'using local storage for important data is not common or recommended', ]; mockGetCluster(mockCluster).as('getCluster'); @@ -418,6 +425,9 @@ describe('LKE cluster updates', () => { .findByTitle(`Recycle ${mockKubeLinode.id}?`) .should('be.visible') .within(() => { + cy.findByText('Delete and recreate this node.', { + exact: false, + }).should('be.visible'); recycleWarningSubstrings.forEach((warning: string) => { cy.findByText(warning, { exact: false }).should('be.visible'); }); @@ -445,6 +455,13 @@ describe('LKE cluster updates', () => { .findByTitle('Recycle node pool?') .should('be.visible') .within(() => { + cy.findByText('Delete and recreate all nodes in this node pool.', { + exact: false, + }).should('be.visible'); + recycleWarningSubstrings.forEach((warning: string) => { + cy.findByText(warning, { exact: false }).should('be.visible'); + }); + ui.button .findByTitle('Recycle Pool Nodes') .should('be.visible') @@ -468,6 +485,9 @@ describe('LKE cluster updates', () => { .findByTitle('Recycle all nodes in cluster?') .should('be.visible') .within(() => { + cy.findByText('Delete and recreate all nodes in this cluster.', { + exact: false, + }).should('be.visible'); recycleWarningSubstrings.forEach((warning: string) => { cy.findByText(warning, { exact: false }).should('be.visible'); }); @@ -502,16 +522,16 @@ describe('LKE cluster updates', () => { const mockNodePool = nodePoolFactory.build({ count: 1, - type: 'g6-standard-1', nodes: kubeLinodeFactory.buildList(1), + type: 'g6-standard-1', }); const mockNodePoolAutoscale = { ...mockNodePool, autoscaler: { enabled: true, - min: autoscaleMin, max: autoscaleMax, + min: autoscaleMin, }, }; @@ -541,30 +561,24 @@ describe('LKE cluster updates', () => { .findByTitle('Autoscale Pool') .should('be.visible') .within(() => { - cy.findByText('Autoscaler').should('be.visible').click(); + cy.findByText('Autoscale').should('be.visible').click(); - cy.findByLabelText('Min') - .should('be.visible') - .click() - .clear() - .type(`${autoscaleMin}`); + cy.findByLabelText('Min').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(`${autoscaleMin}`); cy.findByText(minWarning).should('be.visible'); - cy.findByLabelText('Max') - .should('be.visible') - .click() - .clear() - .type('101'); + cy.findByLabelText('Max').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type('101'); cy.findByText(minWarning).should('not.exist'); cy.findByText(maxWarning).should('be.visible'); - cy.findByLabelText('Max') - .should('be.visible') - .click() - .clear() - .type(`${autoscaleMax}`); + cy.findByLabelText('Max').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(`${autoscaleMax}`); cy.findByText(minWarning).should('not.exist'); cy.findByText(maxWarning).should('not.exist'); @@ -594,7 +608,7 @@ describe('LKE cluster updates', () => { .findByTitle('Autoscale Pool') .should('be.visible') .within(() => { - cy.findByText('Autoscaler').should('be.visible').click(); + cy.findByText('Autoscale').should('be.visible').click(); ui.button .findByTitle('Save Changes') @@ -626,8 +640,8 @@ describe('LKE cluster updates', () => { const mockNodePoolResized = nodePoolFactory.build({ count: 3, - type: 'g6-standard-1', nodes: kubeLinodeFactory.buildList(3), + type: 'g6-standard-1', }); const mockNodePoolInitial = { @@ -705,19 +719,22 @@ describe('LKE cluster updates', () => { .should('be.visible') .should('be.disabled'); - cy.findByText('Resized pool: $12/month (1 node at $12/month)').should( - 'be.visible' - ); + cy.findByText( + 'Current price: $12/month (1 node at $12/month each)' + ).should('be.visible'); + cy.findByText( + 'Resized price: $12/month (1 node at $12/month each)' + ).should('be.visible'); cy.findByLabelText('Add 1') .should('be.visible') .should('be.enabled') - .click() .click(); + cy.focused().click(); cy.findByLabelText('Edit Quantity').should('have.value', '3'); cy.findByText( - 'Resized pool: $36/month (3 nodes at $12/month)' + 'Resized price: $36/month (3 nodes at $12/month each)' ).should('be.visible'); ui.button @@ -759,8 +776,8 @@ describe('LKE cluster updates', () => { cy.findByLabelText('Subtract 1') .should('be.visible') .should('be.enabled') - .click() .click(); + cy.focused().click(); cy.findByText(decreaseSizeWarning).should('be.visible'); cy.findByText(nodeSizeRecommendation).should('be.visible'); @@ -957,8 +974,8 @@ describe('LKE cluster updates', () => { cy.findByTestId('textfield-input') .should('be.visible') .should('have.value', mockCluster.label) - .clear() - .type(`${mockNewCluster.label}{enter}`); + .clear(); + cy.focused().type(`${mockNewCluster.label}{enter}`); }); cy.wait('@updateCluster'); @@ -995,8 +1012,8 @@ describe('LKE cluster updates', () => { cy.findByTestId('textfield-input') .should('be.visible') .should('have.value', mockCluster.label) - .clear() - .type(`${mockErrorCluster.label}{enter}`); + .clear(); + cy.focused().type(`${mockErrorCluster.label}{enter}`); }); // Error message shows when API request fails. @@ -1025,8 +1042,8 @@ describe('LKE cluster updates', () => { const mockNodePoolNoTags = nodePoolFactory.build({ id: 1, - type: mockType.id, nodes: mockNodes, + type: mockType.id, }); const mockNodePoolWithTags = { @@ -1136,11 +1153,10 @@ describe('LKE cluster updates', () => { const mockNodePoolInitial = nodePoolFactory.build({ id: 1, - type: mockType.id, - nodes: mockNodes, labels: { ['example.com/my-app']: 'teams', }, + nodes: mockNodes, taints: [ { effect: 'NoSchedule', @@ -1148,6 +1164,7 @@ describe('LKE cluster updates', () => { value: 'teamA', }, ], + type: mockType.id, }); const mockDrawerTitle = 'Labels and Taints: Linode 2 GB Plan'; @@ -1170,10 +1187,10 @@ describe('LKE cluster updates', () => { it('can delete labels and taints', () => { const mockNodePoolUpdated = nodePoolFactory.build({ id: 1, - type: mockType.id, + labels: {}, nodes: mockNodes, taints: [], - labels: {}, + type: mockType.id, }); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); @@ -1294,24 +1311,24 @@ describe('LKE cluster updates', () => { const mockNewSimpleLabel = 'my_label.-key: my_label.-value'; const mockNewDNSLabel = 'my_label-key.io/app: my_label.-value'; const mockNewTaint: Taint = { + effect: 'NoSchedule', key: 'my_taint.-key', value: 'my_taint.-value', - effect: 'NoSchedule', }; const mockNewDNSTaint: Taint = { + effect: 'NoSchedule', key: 'my_taint-key.io/app', value: 'my_taint.-value', - effect: 'NoSchedule', }; const mockNodePoolUpdated = nodePoolFactory.build({ id: 1, - type: mockType.id, - nodes: mockNodes, - taints: [mockNewTaint, mockNewDNSTaint], labels: { 'my_label-key': 'my_label.-value', 'my_label-key.io/app': 'my_label.-value', }, + nodes: mockNodes, + taints: [mockNewTaint, mockNewDNSTaint], + type: mockType.id, }); cy.visitWithLogin(`/kubernetes/clusters/${mockCluster.id}`); @@ -1369,7 +1386,8 @@ describe('LKE cluster updates', () => { // Confirm labels with simple keys and DNS subdomain keys can be added. [mockNewSimpleLabel, mockNewDNSLabel].forEach((newLabel, index) => { // Confirm form adds a valid new label. - cy.findByLabelText('Label').click().type(newLabel); + cy.findByLabelText('Label').click(); + cy.focused().type(newLabel); ui.button.findByTitle('Add').click(); @@ -1404,9 +1422,8 @@ describe('LKE cluster updates', () => { // Confirm taints with simple keys and DNS subdomain keys can be added. [mockNewTaint, mockNewDNSTaint].forEach((newTaint, index) => { // Confirm form adds a valid new taint. - cy.findByLabelText('Taint') - .click() - .type(`${newTaint.key}: ${newTaint.value}`); + cy.findByLabelText('Taint').click(); + cy.focused().type(`${newTaint.key}: ${newTaint.value}`); ui.autocomplete.findByLabel('Effect').click(); @@ -1529,7 +1546,9 @@ describe('LKE cluster updates', () => { ); invalidLabels.forEach((invalidLabel) => { - cy.findByLabelText('Label').click().clear().type(invalidLabel); + cy.findByLabelText('Label').click(); + cy.focused().clear(); + cy.focused().type(invalidLabel); // Try to submit with invalid label. ui.button.findByTitle('Add').click(); @@ -1541,10 +1560,9 @@ describe('LKE cluster updates', () => { }); // Submit a valid label to enable the 'Save Changes' button. - cy.findByLabelText('Label') - .click() - .clear() - .type('mockKey: mockValue'); + cy.findByLabelText('Label').click(); + cy.focused().clear(); + cy.focused().type('mockKey: mockValue'); ui.button.findByTitle('Add').click(); @@ -1557,7 +1575,9 @@ describe('LKE cluster updates', () => { cy.findByText('Key is required.').should('be.visible'); invalidTaintKeys.forEach((invalidTaintKey, index) => { - cy.findByLabelText('Taint').click().clear().type(invalidTaintKey); + cy.findByLabelText('Taint').click(); + cy.focused().clear(); + cy.focused().type(invalidTaintKey); // Try to submit taint with invalid key. ui.button.findByTitle('Add').click(); @@ -1574,7 +1594,9 @@ describe('LKE cluster updates', () => { }); invalidTaintValues.forEach((invalidTaintValue, index) => { - cy.findByLabelText('Taint').click().clear().type(invalidTaintValue); + cy.findByLabelText('Taint').click(); + cy.focused().clear(); + cy.focused().type(invalidTaintValue); // Try to submit taint with invalid value. ui.button.findByTitle('Add').click(); @@ -1697,12 +1719,12 @@ describe('LKE cluster updates', () => { }); const mockNodePools = [ nodePoolFactory.build({ - nodes: kubeLinodeFactory.buildList(10), count: 10, + nodes: kubeLinodeFactory.buildList(10), }), nodePoolFactory.build({ - nodes: kubeLinodeFactory.buildList(5), count: 5, + nodes: kubeLinodeFactory.buildList(5), }), nodePoolFactory.build({ nodes: [kubeLinodeFactory.build()] }), ]; @@ -1778,7 +1800,9 @@ describe('LKE cluster updates', () => { it('filters the node tables based on selected status filter', () => { const mockCluster = kubernetesClusterFactory.build({ + created: DateTime.local().toISO(), k8s_version: latestKubernetesVersion, + tier: 'enterprise', }); const mockNodePools = [ nodePoolFactory.build({ @@ -1789,6 +1813,7 @@ describe('LKE cluster updates', () => { ], }), nodePoolFactory.build({ + count: 2, nodes: kubeLinodeFactory.buildList(2), }), ]; @@ -1823,6 +1848,9 @@ describe('LKE cluster updates', () => { cy.wait(['@getCluster', '@getNodePools', '@getLinodes']); // Filter is initially set to Show All nodes + cy.findByText( + 'Nodes will appear once cluster provisioning is complete.' + ).should('not.exist'); cy.get(`[data-qa-node-pool-id="${mockNodePools[0].id}"]`).within(() => { cy.get('[data-qa-node-row]').should('have.length', 4); }); @@ -1835,6 +1863,9 @@ describe('LKE cluster updates', () => { ui.autocompletePopper.findByTitle('Running').should('be.visible').click(); // Only Running nodes should be displayed + cy.findByText( + 'Nodes will appear once cluster provisioning is complete.' + ).should('not.exist'); cy.get(`[data-qa-node-pool-id="${mockNodePools[0].id}"]`).within(() => { cy.get('[data-qa-node-row]').should('have.length', 2); }); @@ -1847,6 +1878,9 @@ describe('LKE cluster updates', () => { ui.autocompletePopper.findByTitle('Offline').should('be.visible').click(); // Only Offline nodes should be displayed + cy.findByText( + 'Nodes will appear once cluster provisioning is complete.' + ).should('not.exist'); cy.get(`[data-qa-node-pool-id="${mockNodePools[0].id}"]`).within(() => { cy.get('[data-qa-node-row]').should('have.length', 1); }); @@ -1862,6 +1896,9 @@ describe('LKE cluster updates', () => { .click(); // Only Provisioning nodes should be displayed + cy.findByText( + 'Nodes will appear once cluster provisioning is complete.' + ).should('not.exist'); cy.get(`[data-qa-node-pool-id="${mockNodePools[0].id}"]`).within(() => { cy.get('[data-qa-node-row]').should('have.length', 1); }); @@ -1874,6 +1911,9 @@ describe('LKE cluster updates', () => { ui.autocompletePopper.findByTitle('Show All').should('be.visible').click(); // All nodes are displayed + cy.findByText( + 'Nodes will appear once cluster provisioning is complete.' + ).should('not.exist'); cy.get(`[data-qa-node-pool-id="${mockNodePools[0].id}"]`).within(() => { cy.get('[data-qa-node-row]').should('have.length', 4); }); @@ -1894,17 +1934,17 @@ describe('LKE cluster updates', () => { const mockPlanType = extendType(dcPricingMockLinodeTypes[0]); const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, - region: dcSpecificPricingRegion.id, control_plane: { high_availability: false, }, + k8s_version: latestKubernetesVersion, + region: dcSpecificPricingRegion.id, }); const mockNodePoolResized = nodePoolFactory.build({ count: 3, - type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(3), + type: mockPlanType.id, }); const mockNodePoolInitial = { @@ -1985,25 +2025,25 @@ describe('LKE cluster updates', () => { .should('be.disabled'); cy.findByText( - 'Current pool: $14.40/month (1 node at $14.40/month)' + 'Current price: $14.40/month (1 node at $14.40/month each)' ).should('be.visible'); cy.findByText( - 'Resized pool: $14.40/month (1 node at $14.40/month)' + 'Resized price: $14.40/month (1 node at $14.40/month each)' ).should('be.visible'); cy.findByLabelText('Add 1') .should('be.visible') .should('be.enabled') - .click() - .click() .click(); + cy.focused().click(); + cy.focused().click(); cy.findByLabelText('Edit Quantity').should('have.value', '4'); cy.findByText( - 'Current pool: $14.40/month (1 node at $14.40/month)' + 'Current price: $14.40/month (1 node at $14.40/month each)' ).should('be.visible'); cy.findByText( - 'Resized pool: $57.60/month (4 nodes at $14.40/month)' + 'Resized price: $57.60/month (4 nodes at $14.40/month each)' ).should('be.visible'); cy.findByLabelText('Subtract 1') @@ -2013,7 +2053,7 @@ describe('LKE cluster updates', () => { cy.findByLabelText('Edit Quantity').should('have.value', '3'); cy.findByText( - 'Resized pool: $43.20/month (3 nodes at $14.40/month)' + 'Resized price: $43.20/month (3 nodes at $14.40/month each)' ).should('be.visible'); ui.button @@ -2038,25 +2078,25 @@ describe('LKE cluster updates', () => { const dcSpecificPricingRegion = getRegionById('us-east'); const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, - region: dcSpecificPricingRegion.id, control_plane: { high_availability: false, }, + k8s_version: latestKubernetesVersion, + region: dcSpecificPricingRegion.id, }); const mockPlanType = extendType(dcPricingMockLinodeTypes[0]); const mockNewNodePool = nodePoolFactory.build({ count: 2, - type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(2), + type: mockPlanType.id, }); const mockNodePool = nodePoolFactory.build({ count: 1, - type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(1), + type: mockPlanType.id, }); mockGetCluster(mockCluster).as('getCluster'); @@ -2110,7 +2150,8 @@ describe('LKE cluster updates', () => { // Assert that DC-specific prices are displayed the plan table, then add a node pool with 2 linodes. cy.findByText('$14.40').should('be.visible'); cy.findByText('$0.021').should('be.visible'); - cy.findByLabelText('Add 1').should('be.visible').click().click(); + cy.findByLabelText('Add 1').should('be.visible').click(); + cy.focused().click(); }); // Assert that DC-specific prices are displayed as helper text. @@ -2143,17 +2184,17 @@ describe('LKE cluster updates', () => { const mockPlanType = extendType(dcPricingMockLinodeTypes[2]); const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, - region: dcSpecificPricingRegion.id, control_plane: { high_availability: false, }, + k8s_version: latestKubernetesVersion, + region: dcSpecificPricingRegion.id, }); const mockNodePoolResized = nodePoolFactory.build({ count: 3, - type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(3), + type: mockPlanType.id, }); const mockNodePoolInitial = { @@ -2234,27 +2275,27 @@ describe('LKE cluster updates', () => { .should('be.visible') .should('be.disabled'); - cy.findByText('Current pool: $0/month (1 node at $0/month)').should( - 'be.visible' - ); - cy.findByText('Resized pool: $0/month (1 node at $0/month)').should( - 'be.visible' - ); + cy.findByText( + 'Current price: $0/month (1 node at $0/month each)' + ).should('be.visible'); + cy.findByText( + 'Resized price: $0/month (1 node at $0/month each)' + ).should('be.visible'); cy.findByLabelText('Add 1') .should('be.visible') .should('be.enabled') - .click() - .click() .click(); + cy.focused().click(); + cy.focused().click(); cy.findByLabelText('Edit Quantity').should('have.value', '4'); - cy.findByText('Current pool: $0/month (1 node at $0/month)').should( - 'be.visible' - ); - cy.findByText('Resized pool: $0/month (4 nodes at $0/month)').should( - 'be.visible' - ); + cy.findByText( + 'Current price: $0/month (1 node at $0/month each)' + ).should('be.visible'); + cy.findByText( + 'Resized price: $0/month (4 nodes at $0/month each)' + ).should('be.visible'); ui.button .findByTitle('Save Changes') @@ -2280,23 +2321,23 @@ describe('LKE cluster updates', () => { const mockPlanType = extendType(dcPricingMockLinodeTypes[2]); const mockCluster = kubernetesClusterFactory.build({ - k8s_version: latestKubernetesVersion, - region: dcSpecificPricingRegion.id, control_plane: { high_availability: false, }, + k8s_version: latestKubernetesVersion, + region: dcSpecificPricingRegion.id, }); const mockNewNodePool = nodePoolFactory.build({ count: 2, - type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(2), + type: mockPlanType.id, }); const mockNodePool = nodePoolFactory.build({ count: 1, - type: mockPlanType.id, nodes: kubeLinodeFactory.buildList(1), + type: mockPlanType.id, }); mockGetCluster(mockCluster).as('getCluster'); @@ -2349,7 +2390,8 @@ describe('LKE cluster updates', () => { .within(() => { // Assert that $0 prices are displayed the plan table, then add a node pool with 2 linodes. cy.findAllByText('$0').should('have.length', 2); - cy.findByLabelText('Add 1').should('be.visible').click().click(); + cy.findByLabelText('Add 1').should('be.visible').click(); + cy.focused().click(); }); // Assert that $0 prices are displayed as helper text. @@ -2410,14 +2452,14 @@ describe('LKE ACL updates', () => { */ it('can enable ACL on an LKE cluster with ACL pre-installed and edit IPs', () => { const mockACLOptions = kubernetesControlPlaneACLOptionsFactory.build({ - enabled: false, addresses: { ipv4: ['10.0.3.0/24'], ipv6: undefined }, + enabled: false, }); const mockUpdatedACLOptions1 = kubernetesControlPlaneACLOptionsFactory.build( { + addresses: { ipv4: ['10.0.0.0/24'], ipv6: undefined }, enabled: true, 'revision-id': mockRevisionId, - addresses: { ipv4: ['10.0.0.0/24'], ipv6: undefined }, } ); const mockControlPaneACL = kubernetesControlPlaneACLFactory.build({ @@ -2479,14 +2521,13 @@ describe('LKE ACL updates', () => { 'have.value', mockACLOptions['revision-id'] ); - cy.findByLabelText('Revision ID').clear().type(mockRevisionId); + cy.findByLabelText('Revision ID').clear(); + cy.focused().type(mockRevisionId); // Addresses section: confirm current IPv4 value and enter new IP - cy.findByDisplayValue('10.0.3.0/24') - .should('be.visible') - .click() - .clear() - .type('10.0.0.0/24'); + cy.findByDisplayValue('10.0.3.0/24').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type('10.0.0.0/24'); // submit ui.button @@ -2511,8 +2552,6 @@ describe('LKE ACL updates', () => { // update mocks const mockUpdatedACLOptions2 = kubernetesControlPlaneACLOptionsFactory.build( { - enabled: true, - 'revision-id': mockRevisionId, addresses: { ipv4: ['10.0.0.0/24'], ipv6: [ @@ -2520,6 +2559,8 @@ describe('LKE ACL updates', () => { 'f4a2:b849:4a24:d0d9:15f0:704b:f943:718f', ], }, + enabled: true, + 'revision-id': mockRevisionId, } ); const mockUpdatedControlPlaneACL2 = kubernetesControlPlaneACLFactory.build( @@ -2559,16 +2600,16 @@ describe('LKE ACL updates', () => { cy.findByDisplayValue('10.0.0.0/24').should('be.visible'); cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') .should('be.visible') - .click() - .type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); + .click(); + cy.focused().type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); cy.findByText('Add IPv6 Address') .should('be.visible') .should('be.enabled') .click(); cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-1') .should('be.visible') - .click() - .type('f4a2:b849:4a24:d0d9:15f0:704b:f943:718f'); + .click(); + cy.focused().type('f4a2:b849:4a24:d0d9:15f0:704b:f943:718f'); // submit ui.button @@ -2606,21 +2647,21 @@ describe('LKE ACL updates', () => { }); /** - * - Confirms ACL can be disabled from the summary page + * - Confirms ACL can be disabled from the summary page (for standard tier only) * - Confirms both IPv4 and IPv6 can be updated and that drawer updates as a result */ - it('can disable ACL and edit IPs', () => { + it('can disable ACL on a standard tier cluster and edit IPs', () => { const mockACLOptions = kubernetesControlPlaneACLOptionsFactory.build({ - enabled: true, addresses: { ipv4: undefined, ipv6: undefined }, + enabled: true, }); const mockUpdatedACLOptions1 = kubernetesControlPlaneACLOptionsFactory.build( { - enabled: false, addresses: { ipv4: ['10.0.0.0/24'], ipv6: ['8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'], }, + enabled: false, } ); const mockControlPaneACL = kubernetesControlPlaneACLFactory.build({ @@ -2686,8 +2727,8 @@ describe('LKE ACL updates', () => { // Addresses Section: update IPv4 cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') .should('be.visible') - .click() - .type('10.0.0.0/24'); + .click(); + cy.focused().type('10.0.0.0/24'); cy.findByText('Add IPv4 Address') .should('be.visible') .should('be.enabled') @@ -2695,8 +2736,8 @@ describe('LKE ACL updates', () => { // update IPv6 cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') .should('be.visible') - .click() - .type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); + .click(); + cy.focused().type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); cy.findByText('Add IPv6 Address') .should('be.visible') .should('be.enabled') @@ -2748,8 +2789,8 @@ describe('LKE ACL updates', () => { */ it('can enable ACL on an LKE cluster with ACL not pre-installed and edit IPs', () => { const mockACLOptions = kubernetesControlPlaneACLOptionsFactory.build({ - enabled: true, addresses: { ipv4: ['10.0.0.0/24'] }, + enabled: true, }); const mockControlPaneACL = kubernetesControlPlaneACLFactory.build({ acl: mockACLOptions, @@ -2811,13 +2852,13 @@ describe('LKE ACL updates', () => { cy.findByText('Addresses').should('be.visible'); cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') .should('be.visible') - .click() - .type('10.0.0.0/24'); + .click(); + cy.focused().type('10.0.0.0/24'); cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') .should('be.visible') - .click() - .type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); + .click(); + cy.focused().type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); // submit ui.button @@ -2842,8 +2883,8 @@ describe('LKE ACL updates', () => { */ it('can handle validation and API errors', () => { const mockACLOptions = kubernetesControlPlaneACLOptionsFactory.build({ - enabled: true, addresses: { ipv4: undefined, ipv6: undefined }, + enabled: true, }); const mockControlPaneACL = kubernetesControlPlaneACLFactory.build({ acl: mockACLOptions, @@ -2877,17 +2918,17 @@ describe('LKE ACL updates', () => { // Confirm ACL IP validation works as expected for IPv4 cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') .should('be.visible') - .click() - .type('invalid ip'); + .click(); + cy.focused().type('invalid ip'); // click out of textbox and confirm error is visible cy.contains('Addresses').should('be.visible').click(); cy.contains('Must be a valid IPv4 address.').should('be.visible'); // enter valid IP cy.findByLabelText('IPv4 Addresses or CIDRs ip-address-0') .should('be.visible') - .click() - .clear() - .type('10.0.0.0/24'); + .click(); + cy.focused().clear(); + cy.focused().type('10.0.0.0/24'); // Click out of textbox and confirm error is gone cy.contains('Addresses').should('be.visible').click(); cy.contains('Must be a valid IPv4 address.').should('not.exist'); @@ -2895,17 +2936,17 @@ describe('LKE ACL updates', () => { // Confirm ACL IP validation works as expected for IPv6 cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') .should('be.visible') - .click() - .type('invalid ip'); + .click(); + cy.focused().type('invalid ip'); // click out of textbox and confirm error is visible cy.findByText('Addresses').should('be.visible').click(); cy.contains('Must be a valid IPv6 address.').should('be.visible'); // enter valid IP cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') .should('be.visible') - .click() - .clear() - .type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); + .click(); + cy.focused().clear(); + cy.focused().type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); // Click out of textbox and confirm error is gone cy.findByText('Addresses').should('be.visible').click(); cy.contains('Must be a valid IPv6 address.').should('not.exist'); @@ -2922,5 +2963,130 @@ describe('LKE ACL updates', () => { cy.wait(['@updateControlPlaneACLError']); cy.contains(mockErrorMessage).should('be.visible'); }); + + it('can handle validation for an enterprise cluster', () => { + const mockEnterpriseCluster = kubernetesClusterFactory.build({ + tier: 'enterprise', + }); + const mockACLOptions = kubernetesControlPlaneACLOptionsFactory.build({ + addresses: { ipv4: [], ipv6: [] }, + enabled: true, + }); + const mockUpdatedOptions = kubernetesControlPlaneACLOptionsFactory.build({ + addresses: { + ipv4: [], + ipv6: ['8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'], + }, + enabled: true, + }); + const mockControlPaneACL = kubernetesControlPlaneACLFactory.build({ + acl: mockACLOptions, + }); + const mockUpdatedControlPaneACL = kubernetesControlPlaneACLFactory.build({ + acl: mockUpdatedOptions, + }); + + mockGetCluster(mockEnterpriseCluster).as('getCluster'); + mockGetControlPlaneACL(mockEnterpriseCluster.id, mockControlPaneACL).as( + 'getControlPlaneACL' + ); + mockUpdateControlPlaneACL( + mockEnterpriseCluster.id, + mockUpdatedControlPaneACL + ).as('updateControlPlaneACL'); + + cy.visitWithLogin(`/kubernetes/clusters/${mockEnterpriseCluster.id}`); + cy.wait(['@getAccount', '@getCluster', '@getControlPlaneACL']); + + cy.contains('Control Plane ACL').should('be.visible'); + ui.button + .findByTitle('Enabled (0 IP Addresses)') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle(`Control Plane ACL for ${mockEnterpriseCluster.label}`) + .should('be.visible') + .within(() => { + // Confirm the checkbox is not checked by default + cy.findByRole('checkbox', { name: /Provide an ACL later/ }).should( + 'not.be.checked' + ); + + cy.findByLabelText('Revision ID').click(); + cy.focused().clear(); + cy.focused().type('1'); + + // Try to submit the form without any IPs + ui.button + .findByTitle('Update') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm validation error prevents this + cy.findByText( + 'At least one IP address or CIDR range is required for LKE Enterprise.' + ).should('be.visible'); + + // Add at least one IP + cy.findByText('Add IPv6 Address').click(); + cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') + .should('be.visible') + .click(); + cy.focused().clear(); + cy.focused().type('8e61:f9e9:8d40:6e0a:cbff:c97a:2692:827e'); + + // Resubmit the form + ui.button + .findByTitle('Update') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm error message disappears + cy.findByText( + 'At least one IP address or CIDR range is required for LKE Enterprise.' + ).should('not.exist'); + }); + + cy.wait('@updateControlPlaneACL'); + + ui.button + .findByTitle('Enabled (1 IP Address)') + .should('be.visible') + .should('be.enabled') + .click(); + + ui.drawer + .findByTitle(`Control Plane ACL for ${mockEnterpriseCluster.label}`) + .should('be.visible') + .within(() => { + // Clear the existing IP + cy.findByLabelText('IPv6 Addresses or CIDRs ip-address-0') + .should('be.visible') + .click(); + cy.focused().clear(); + + // Check the acknowledgement checkbox + cy.findByRole('checkbox', { name: /Provide an ACL later/ }).click(); + + // Confirm the form can submit without any IPs if the acknowledgement is checked + ui.button + .findByTitle('Update') + .scrollIntoView() + .should('be.visible') + .should('be.enabled') + .click(); + + // Confirm error message disappears + cy.contains( + 'At least one IP address or CIDR range is required for LKE Enterprise.' + ).should('not.exist'); + }); + }); }); }); diff --git a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts index e2aff4b1a1c..c32b7eb1d4e 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/smoke-lke-create.spec.ts @@ -1,7 +1,17 @@ -import { kubernetesClusterFactory } from '@src/factories'; -import { randomLabel, randomNumber } from 'support/util/random'; +import { + accountUserFactory, + grantsFactory, + kubernetesClusterFactory, + profileFactory, +} from '@src/factories'; +import { mockGetUser } from 'support/intercepts/account'; import { mockCreateCluster } from 'support/intercepts/lke'; +import { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; import { ui } from 'support/ui'; +import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; /** @@ -54,21 +64,20 @@ const minimumNodeNotice = describe('LKE Create Cluster', () => { it('Simple Page Check', () => { const mockCluster = kubernetesClusterFactory.build({ - label: randomLabel(), id: randomNumber(10000, 99999), + label: randomLabel(), }); mockCreateCluster(mockCluster).as('createCluster'); cy.visitWithLogin('/kubernetes/create'); cy.findByText('Add Node Pools').should('be.visible'); - cy.findByLabelText('Cluster Label').click().type(mockCluster.label); + cy.findByLabelText('Cluster Label').click(); + cy.focused().type(mockCluster.label); ui.regionSelect.find().click().type(`${chooseRegion().label}{enter}`); - cy.findByText('Kubernetes Version') - .should('be.visible') - .click() - .type('{enter}'); + cy.findByText('Kubernetes Version').should('be.visible').click(); + cy.focused().type('{enter}'); cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click(); @@ -97,4 +106,68 @@ describe('LKE Create Cluster', () => { `/kubernetes/clusters/${mockCluster.id}/summary` ); }); + + it('should not allow creating cluster for restricted users', () => { + // Mock setup for user profile, account user, and user grants with restricted permissions, + // simulating a default user without the ability to add Linodes. + const mockProfile = profileFactory.build({ + restricted: true, + username: randomLabel(), + }); + + const mockUser = accountUserFactory.build({ + restricted: true, + user_type: 'default', + username: mockProfile.username, + }); + + const mockGrants = grantsFactory.build({ + global: { + add_kubernetes: false, + }, + }); + + const mockCluster = kubernetesClusterFactory.build({ + id: randomNumber(10000, 99999), + label: randomLabel(), + }); + + mockGetProfile(mockProfile); + mockGetProfileGrants(mockGrants); + mockGetUser(mockUser); + mockCreateCluster(mockCluster).as('createCluster'); + + cy.visitWithLogin('/kubernetes/create'); + cy.findByText('Add Node Pools').should('be.visible'); + + // Confirm that a notice should be shown informing the user they do not have permission to create a Cluster. + cy.findByText( + "You don't have permissions to create LKE Clusters. Please contact your account administrator to request the necessary permissions." + ).should('be.visible'); + + // Confirm that "Cluster Label" field is disabled. + cy.findByLabelText('Cluster Label') + .should('be.visible') + .should('be.disabled'); + + // Confirm that "Region" field is disabled. + ui.regionSelect.find().should('be.visible').should('be.disabled'); + + // Confirm that "Kubernetes Version" field is disabled. + cy.get('[data-qa-autocomplete="Kubernetes Version"] input') + .should('be.visible') + .should('be.disabled'); + + // Confirm that "HA" field is disabled. + cy.get('[data-testid="ha-radio-button-yes"]').should('be.visible').click(); + cy.get('[data-testid="ha-radio-button-yes"]').should('not.be.checked'); + + cy.get('[data-testid="kube-checkout-bar"]').within(() => { + // Confirm that "Create Cluster" field is disabled. + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.disabled'); + }); + }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts index a93a904c248..ab5ea1349d0 100644 --- a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts @@ -1,34 +1,35 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import type { Linode } from '@linode/api-v4'; import { - linodeFactory, - linodeBackupsFactory, accountSettingsFactory, createLinodeRequestFactory, + linodeBackupsFactory, + linodeFactory, } from '@src/factories'; import { authenticate } from 'support/api/authentication'; +import { expectManagedDisabled } from 'support/api/managed'; +import { dcPricingMockLinodeTypesForBackups } from 'support/constants/dc-specific-pricing'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { mockGetAccountSettings, mockUpdateAccountSettings, } from 'support/intercepts/account'; import { - mockGetLinodes, + interceptCancelLinodeBackups, + interceptCreateLinodeSnapshot, + interceptEnableLinodeBackups, + interceptGetLinode, mockEnableLinodeBackups, mockGetLinodeType, mockGetLinodeTypes, - interceptEnableLinodeBackups, - interceptGetLinode, - interceptCreateLinodeSnapshot, - interceptCancelLinodeBackups, + mockGetLinodes, } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; import { randomLabel } from 'support/util/random'; -import { dcPricingMockLinodeTypesForBackups } from 'support/constants/dc-specific-pricing'; import { chooseRegion } from 'support/util/regions'; -import { expectManagedDisabled } from 'support/api/managed'; -import { createTestLinode } from 'support/util/linodes'; -import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; + +import type { Linode } from '@linode/api-v4'; const BackupsCancellationNote = 'Once backups for this Linode have been canceled, you cannot re-enable them for 24 hours.'; @@ -57,10 +58,10 @@ describe('linode backups', () => { // Create a Linode that is not booted and which does not have backups enabled. const createLinodeRequest = createLinodeRequestFactory.build({ - label: randomLabel(), - region: chooseRegion().id, backups_enabled: false, booted: false, + label: randomLabel(), + region: chooseRegion().id, }); cy.defer( @@ -166,10 +167,10 @@ describe('linode backups', () => { cy.tag('method:e2e'); // Create a Linode that is not booted and which has backups enabled. const createLinodeRequest = createLinodeRequestFactory.build({ - label: randomLabel(), - region: chooseRegion().id, backups_enabled: true, booted: false, + label: randomLabel(), + region: chooseRegion().id, }); const snapshotName = randomLabel(); @@ -202,10 +203,8 @@ describe('linode backups', () => { .should('be.disabled'); // Enter a snapshot name, click "Take Snapshot". - cy.findByLabelText('Name Snapshot') - .should('be.visible') - .clear() - .type(snapshotName); + cy.findByLabelText('Name Snapshot').should('be.visible').clear(); + cy.focused().type(snapshotName); ui.button .findByTitle('Take Snapshot') @@ -329,27 +328,27 @@ describe('"Enable Linode Backups" banner', () => { // // See `dcPricingMockLinodeTypes` exported from `support/constants/dc-specific-pricing.ts`. linodeFactory.build({ + backups: { enabled: false }, label: randomLabel(), region: 'us-ord', - backups: { enabled: false }, type: dcPricingMockLinodeTypesForBackups[0].id, }), linodeFactory.build({ + backups: { enabled: false }, label: randomLabel(), region: 'us-east', - backups: { enabled: false }, type: dcPricingMockLinodeTypesForBackups[1].id, }), linodeFactory.build({ + backups: { enabled: false }, label: randomLabel(), region: 'us-west', - backups: { enabled: false }, type: dcPricingMockLinodeTypesForBackups[2].id, }), linodeFactory.build({ + backups: { enabled: false }, label: randomLabel(), region: 'us-central', - backups: { enabled: false }, type: 'g6-nanode-1', }), ]; 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 e03dbca74c4..883fc888add 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -1,46 +1,48 @@ +import { linodeConfigInterfaceFactory } from '@linode/utilities'; import { + VLANFactory, createLinodeRequestFactory, linodeConfigFactory, - LinodeConfigInterfaceFactory, linodeFactory, - VLANFactory, volumeFactory, } from '@src/factories'; +import { authenticate } from 'support/api/authentication'; +import { + dcPricingDocsLabel, + dcPricingDocsUrl, + dcPricingMockLinodeTypes, + dcPricingRegionDifferenceNotice, +} from 'support/constants/dc-specific-pricing'; +import { + LINODE_CLONE_TIMEOUT, + LINODE_CREATE_TIMEOUT, +} from 'support/constants/linodes'; +import { mockGetLinodeConfigs } from 'support/intercepts/configs'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { interceptCloneLinode, + mockCloneLinode, + mockCreateLinode, mockGetLinodeDetails, - mockGetLinodes, mockGetLinodeType, mockGetLinodeTypes, - mockCreateLinode, - mockCloneLinode, mockGetLinodeVolumes, + mockGetLinodes, } from 'support/intercepts/linodes'; -import { linodeCreatePage } from 'support/ui/pages'; import { mockGetVLANs } from 'support/intercepts/vlans'; import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; +import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; import { - dcPricingMockLinodeTypes, - dcPricingRegionDifferenceNotice, - dcPricingDocsLabel, - dcPricingDocsUrl, -} from 'support/constants/dc-specific-pricing'; -import { chooseRegion, getRegionById } from 'support/util/regions'; -import { + randomIp, randomLabel, randomNumber, randomString, - randomIp, } from 'support/util/random'; -import { authenticate } from 'support/api/authentication'; -import { cleanUp } from 'support/util/cleanup'; -import { createTestLinode } from 'support/util/linodes'; -import { - LINODE_CLONE_TIMEOUT, - LINODE_CREATE_TIMEOUT, -} from 'support/constants/linodes'; +import { chooseRegion, getRegionById } from 'support/util/regions'; + import type { Linode } from '@linode/api-v4'; -import { mockGetLinodeConfigs } from 'support/intercepts/configs'; /** * Returns the Cloud Manager URL to clone a given Linode. @@ -60,6 +62,11 @@ describe('clone linode', () => { before(() => { cleanUp('linodes'); }); + beforeEach(() => { + mockAppendFeatureFlags({ + linodeInterfaces: { enabled: false }, + }); + }); /* * - Confirms Linode Clone flow via the Linode details page. @@ -69,9 +76,9 @@ describe('clone linode', () => { cy.tag('method:e2e', 'purpose:dcTesting'); const linodeRegion = chooseRegion({ capabilities: ['Vlans'] }); const linodePayload = createLinodeRequestFactory.build({ + booted: false, label: randomLabel(), region: linodeRegion.id, - booted: false, type: 'g6-nanode-1', }); @@ -150,7 +157,7 @@ describe('clone linode', () => { type: null, }); const mockVolume = volumeFactory.build(); - const mockPublicConfigInterface = LinodeConfigInterfaceFactory.build({ + const mockPublicConfigInterface = linodeConfigInterfaceFactory.build({ ipam_address: null, purpose: 'public', }); @@ -162,17 +169,17 @@ describe('clone linode', () => { ], }); const mockVlan = VLANFactory.build({ + cidr_block: `${randomIp()}/24`, id: randomNumber(), label: randomLabel(), - region: mockLinodeRegion.id, - cidr_block: `${randomIp()}/24`, linodes: [], + region: mockLinodeRegion.id, }); const linodeNullTypePayload = createLinodeRequestFactory.build({ + booted: false, label: mockLinode.label, region: mockLinodeRegion.id, - booted: false, }); const newLinodeLabel = `${linodeNullTypePayload.label}-clone`; const clonedLinode = { @@ -215,11 +222,10 @@ describe('clone linode', () => { }); // Confirm that VLAN attachment is listed in summary, then create Linode. - cy.get('[data-qa-linode-create-summary]') - .scrollIntoView() - .within(() => { - cy.findByText('VLAN Attached').should('be.visible'); - }); + cy.get('[data-qa-linode-create-summary]').scrollIntoView(); + cy.get('[data-qa-linode-create-summary]').within(() => { + cy.findByText('VLAN Attached').should('be.visible'); + }); ui.button .findByTitle('Create Linode') diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-in-core-region.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-in-core-region.spec.ts index cc0b2676087..7c82a1177d0 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-in-core-region.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-in-core-region.spec.ts @@ -1,4 +1,3 @@ -import { linodeFactory, regionFactory } from 'src/factories'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateLinode } from 'support/intercepts/linodes'; import { @@ -9,6 +8,8 @@ import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomString } from 'support/util/random'; +import { linodeFactory, regionFactory } from 'src/factories'; + describe('Create Linode in a Core Region', () => { /* * - Confirms Linode create flow can be completed with a core region diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-in-distributed-region.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-in-distributed-region.spec.ts index 9c142dca96c..746117f2952 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-in-distributed-region.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-in-distributed-region.spec.ts @@ -1,5 +1,3 @@ -import { Region } from '@linode/api-v4'; -import { linodeFactory, linodeTypeFactory, regionFactory } from 'src/factories'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateLinode, @@ -14,6 +12,10 @@ import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomString } from 'support/util/random'; import { extendRegion } from 'support/util/regions'; +import { linodeFactory, linodeTypeFactory, regionFactory } from 'src/factories'; + +import type { Region } from '@linode/api-v4'; + describe('Create Linode in Distributed Region', () => { /* * - Confirms Linode create flow can be completed with a distributed region @@ -28,9 +30,9 @@ describe('Create Linode in Distributed Region', () => { const mockRegion = extendRegion(regionFactory.build(mockRegionOptions)); const mockLinodeTypes = [ linodeTypeFactory.build({ + class: 'nanode', id: 'nanode-edge-1', label: 'Nanode 1GB', - class: 'nanode', }), ]; const mockLinode = linodeFactory.build({ diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts index 608791634f8..cc1fd3f1dda 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts @@ -2,13 +2,14 @@ * @file Smoke tests for Linode Create flow across common mobile viewport sizes. */ -import { linodeFactory } from 'src/factories'; import { MOBILE_VIEWPORTS } from 'support/constants/environment'; +import { mockCreateLinode } from 'support/intercepts/linodes'; +import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { ui } from 'support/ui'; -import { mockCreateLinode } from 'support/intercepts/linodes'; + +import { linodeFactory } from 'src/factories'; describe('Linode create mobile smoke', () => { MOBILE_VIEWPORTS.forEach((viewport) => { @@ -35,13 +36,12 @@ describe('Linode create mobile smoke', () => { linodeCreatePage.setLabel(mockLinode.label); linodeCreatePage.setRootPassword(randomString(32)); - cy.get('[data-qa-linode-create-summary]') - .scrollIntoView() - .within(() => { - cy.findByText('Nanode 1 GB').should('be.visible'); - cy.findByText('Ubuntu 24.04 LTS').should('be.visible'); - cy.findByText(mockLinodeRegion.label).should('be.visible'); - }); + cy.get('[data-qa-linode-create-summary]').scrollIntoView(); + cy.get('[data-qa-linode-create-summary]').within(() => { + cy.findByText('Nanode 1 GB').should('be.visible'); + cy.findByText('Ubuntu 24.04 LTS').should('be.visible'); + cy.findByText(mockLinodeRegion.label).should('be.visible'); + }); ui.button .findByTitle('Create Linode') diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts index 069fc806b06..914b1dc8d3a 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-region-select.spec.ts @@ -1,6 +1,6 @@ -import { ui } from 'support/ui'; import { regionFactory } from '@src/factories'; import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui'; import { extendRegion } from 'support/util/regions'; import type { ExtendedRegion } from 'support/util/regions'; diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts index e15151274c0..2ec04d6fcc7 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts @@ -2,12 +2,17 @@ * @file Linode Create view code snippets tests. */ +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { ui } from 'support/ui'; - -import { randomLabel, randomString } from 'support/util/random'; import { linodeCreatePage } from 'support/ui/pages'; +import { randomLabel, randomString } from 'support/util/random'; describe('Create Linode flow to validate code snippet modal', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + linodeInterfaces: { enabled: false }, + }); + }); /* * tests for create Linode flow to validate code snippet modal. */ diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts index 2b6399144be..589c9e76465 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-add-ons.spec.ts @@ -1,4 +1,3 @@ -import { linodeFactory } from 'src/factories'; import { mockCreateLinode, mockGetLinodeDetails, @@ -8,6 +7,8 @@ import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { linodeFactory } from 'src/factories'; + describe('Create Linode with Add-ons', () => { /* * - Confirms UI flow to create a Linode with backups using mock API data. @@ -37,11 +38,10 @@ describe('Create Linode with Add-ons', () => { linodeCreatePage.checkEUAgreements(); // Confirm Backups assignment indicator is shown in Linode summary. - cy.get('[data-qa-linode-create-summary]') - .scrollIntoView() - .within(() => { - cy.findByText('Backups').should('be.visible'); - }); + cy.get('[data-qa-linode-create-summary]').scrollIntoView(); + cy.get('[data-qa-linode-create-summary]').within(() => { + cy.findByText('Backups').should('be.visible'); + }); // Create Linode and confirm contents of outgoing API request payload. ui.button @@ -91,11 +91,10 @@ describe('Create Linode with Add-ons', () => { linodeCreatePage.checkPrivateIPs(); // Confirm Private IP assignment indicator is shown in Linode summary. - cy.get('[data-qa-linode-create-summary]') - .scrollIntoView() - .within(() => { - cy.findByText('Private IP').should('be.visible'); - }); + cy.get('[data-qa-linode-create-summary]').scrollIntoView(); + cy.get('[data-qa-linode-create-summary]').within(() => { + cy.findByText('Private IP').should('be.visible'); + }); // Create Linode and confirm contents of outgoing API request payload. ui.button diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts index bb2b40b205d..ed5ee08e51d 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-dc-specific-pricing.spec.ts @@ -1,18 +1,18 @@ -import { ui } from 'support/ui'; -import { randomLabel } from 'support/util/random'; -import { getRegionById } from 'support/util/regions'; import { linodeFactory } from '@src/factories'; import { - dcPricingPlanPlaceholder, - dcPricingMockLinodeTypes, dcPricingDocsLabel, dcPricingDocsUrl, + dcPricingMockLinodeTypes, + dcPricingPlanPlaceholder, } from 'support/constants/dc-specific-pricing'; import { mockCreateLinode, mockGetLinodeType, mockGetLinodeTypes, } from 'support/intercepts/linodes'; +import { ui } from 'support/ui'; +import { randomLabel } from 'support/util/random'; +import { getRegionById } from 'support/util/regions'; describe('Create Linode with DC-specific pricing', () => { /* diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts index 72ac16d81a2..0c858da8a0a 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-disk-encryption.spec.ts @@ -1,28 +1,30 @@ -import { ui } from 'support/ui'; import { - linodeFactory, accountFactory, + linodeFactory, linodeTypeFactory, regionFactory, } from '@src/factories'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockCreateLinode, + mockGetLinodeTypes, +} from 'support/intercepts/linodes'; import { mockGetRegionAvailability, mockGetRegions, } from 'support/intercepts/regions'; -import { mockGetAccount } from 'support/intercepts/account'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; import { makeFeatureFlagData } from 'support/util/feature-flags'; +import { randomLabel, randomString } from 'support/util/random'; +import { extendRegion } from 'support/util/regions'; + import { checkboxTestId, headerTestId, } from 'src/components/Encryption/constants'; -import { extendRegion } from 'support/util/regions'; -import { linodeCreatePage } from 'support/ui/pages'; -import { - mockCreateLinode, - mockGetLinodeTypes, -} from 'support/intercepts/linodes'; -import { randomLabel, randomString } from 'support/util/random'; + import type { Region } from '@linode/api-v4'; describe('Create Linode with Disk Encryption', () => { @@ -126,9 +128,9 @@ describe('Create Linode with Disk Encryption', () => { ]; const mockLinodeType = linodeTypeFactory.build({ + class: 'nanode', id: 'nanode-edge-1', label: 'Nanode 1GB', - class: 'nanode', }); /* 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 daf52a7b707..504e0acbb64 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,24 +1,31 @@ +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { - linodeFactory, - firewallFactory, - firewallTemplateFactory, -} from 'src/factories'; + mockCreateFirewall, + mockCreateFirewallError, + mockGetFirewalls, + mockGetTemplate, +} from 'support/intercepts/firewalls'; import { mockCreateLinode, mockGetLinodeDetails, } from 'support/intercepts/linodes'; -import { - mockGetFirewalls, - mockCreateFirewall, - mockGetTemplate, - mockCreateFirewallError, -} from 'support/intercepts/firewalls'; import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { + firewallFactory, + firewallTemplateFactory, + linodeFactory, +} from 'src/factories'; + describe('Create Linode with Firewall', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + linodeInterfaces: { enabled: false }, + }); + }); /* * - Confirms UI flow to create a Linode with an existing Firewall using mock API data. * - Confirms that Firewall is reflected in create summary section. @@ -51,7 +58,8 @@ describe('Create Linode with Firewall', () => { linodeCreatePage.setRootPassword(randomString(32)); // Confirm that mocked Firewall is shown in the Autocomplete, and then select it. - cy.findByText('Assign Firewall').click().type(`${mockFirewall.label}`); + cy.findByText('Assign Firewall').click(); + cy.focused().type(`${mockFirewall.label}`); ui.autocompletePopper .findByTitle(mockFirewall.label) @@ -59,11 +67,10 @@ describe('Create Linode with Firewall', () => { .click(); // 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'); - }); + cy.get('[data-qa-linode-create-summary]').scrollIntoView(); + cy.get('[data-qa-linode-create-summary]').within(() => { + cy.findByText('Firewall Assigned').should('be.visible'); + }); // Create Linode and confirm contents of outgoing API request payload. ui.button @@ -126,7 +133,8 @@ describe('Create Linode with Firewall', () => { cy.get('[data-testid="submit"]').click(); cy.findByText('Label is required.'); // Fill out and submit firewall create form. - cy.contains('Label').click().type(mockFirewall.label); + cy.contains('Label').click(); + cy.focused().type(mockFirewall.label); ui.buttonGroup .findButtonByTitle('Create Firewall') .should('be.visible') @@ -140,7 +148,8 @@ describe('Create Linode with Firewall', () => { ); // Confirm that mocked Firewall is shown in the Autocomplete, and then select it. - cy.findByText('Assign Firewall').click().type(`${mockFirewall.label}`); + cy.findByText('Assign Firewall').click(); + cy.focused().type(`${mockFirewall.label}`); ui.autocompletePopper .findByTitle(mockFirewall.label) @@ -148,11 +157,10 @@ describe('Create Linode with Firewall', () => { .click(); // 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'); - }); + cy.get('[data-qa-linode-create-summary]').scrollIntoView(); + cy.get('[data-qa-linode-create-summary]').within(() => { + cy.findByText('Firewall Assigned').should('be.visible'); + }); // Create Linode and confirm contents of outgoing API request payload. ui.button @@ -263,11 +271,10 @@ describe('Create Linode with Firewall', () => { 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'); - }); + cy.get('[data-qa-linode-create-summary]').scrollIntoView(); + cy.get('[data-qa-linode-create-summary]').within(() => { + cy.findByText('Firewall Assigned').should('be.visible'); + }); // Create Linode and confirm contents of outgoing API request payload. ui.button diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts index dd9b1fa1153..1deaa3b4f09 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-ssh-key.spec.ts @@ -1,15 +1,16 @@ +import { mockGetUser, mockGetUsers } from 'support/intercepts/account'; +import { mockCreateLinode } from 'support/intercepts/linodes'; +import { mockCreateSSHKey } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + import { accountUserFactory, linodeFactory, sshKeyFactory, } from 'src/factories'; -import { randomLabel, randomNumber, randomString } from 'support/util/random'; -import { chooseRegion } from 'support/util/regions'; -import { mockGetUser, mockGetUsers } from 'support/intercepts/account'; -import { mockCreateLinode } from 'support/intercepts/linodes'; -import { linodeCreatePage } from 'support/ui/pages'; -import { ui } from 'support/ui'; -import { mockCreateSSHKey } from 'support/intercepts/profile'; describe('Create Linode with SSH Key', () => { /* @@ -30,8 +31,8 @@ describe('Create Linode with SSH Key', () => { }); const mockUser = accountUserFactory.build({ - username: randomLabel(), ssh_keys: [mockSshKey.label], + username: randomLabel(), }); mockGetUsers([mockUser]); @@ -47,8 +48,8 @@ describe('Create Linode with SSH Key', () => { linodeCreatePage.setRootPassword(randomString(32)); // Confirm that SSH key is listed, then select it. + cy.findByText(mockSshKey.label).scrollIntoView(); cy.findByText(mockSshKey.label) - .scrollIntoView() .should('be.visible') .closest('tr') .within(() => { @@ -90,8 +91,8 @@ describe('Create Linode with SSH Key', () => { }); const mockUser = accountUserFactory.build({ - username: randomLabel(), ssh_keys: [], + username: randomLabel(), }); const mockUserWithKey = { @@ -113,8 +114,8 @@ describe('Create Linode with SSH Key', () => { linodeCreatePage.setRootPassword(randomString(32)); // Confirm that no SSH keys are listed for the mocked user. + cy.findByText(mockUser.username).scrollIntoView(); cy.findByText(mockUser.username) - .scrollIntoView() .should('be.visible') .closest('tr') .within(() => { @@ -148,8 +149,8 @@ describe('Create Linode with SSH Key', () => { cy.wait(['@createSSHKey', '@refetchUsers']); // Confirm that the new SSH key is listed, and select it to be added to the Linode. + cy.findByText(mockSshKey.label).scrollIntoView(); cy.findByText(mockSshKey.label) - .scrollIntoView() .should('be.visible') .closest('tr') .within(() => { diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts index 009aa74305d..d112ad97449 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-user-data.spec.ts @@ -1,4 +1,3 @@ -import { imageFactory, linodeFactory, regionFactory } from 'src/factories'; import { mockGetAllImages, mockGetImage } from 'support/intercepts/images'; import { mockCreateLinode, @@ -10,6 +9,8 @@ import { linodeCreatePage } from 'support/ui/pages'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { imageFactory, linodeFactory, regionFactory } from 'src/factories'; + describe('Create Linode with user data', () => { /* * - Confirms UI flow to create a Linode with cloud-init user data specified. @@ -111,15 +112,15 @@ describe('Create Linode with user data', () => { region: linodeRegion.id, }); const mockImage = imageFactory.build({ - id: `linode/${randomLabel()}`, - label: randomLabel(), - created_by: 'linode', - is_public: true, - vendor: 'Debian', // `cloud-init` is omitted from Image capabilities. capabilities: [], + created_by: 'linode', // null eol so that the image is not deprecated eol: null, + id: `linode/${randomLabel()}`, + is_public: true, + label: randomLabel(), + vendor: 'Debian', }); mockGetImage(mockImage.id, mockImage); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts index 270b9de1072..7515c251c16 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts @@ -1,18 +1,26 @@ -import { linodeFactory, regionFactory, VLANFactory } from 'src/factories'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockCreateLinode } from 'support/intercepts/linodes'; import { mockGetRegions } from 'support/intercepts/regions'; +import { mockGetVLANs } from 'support/intercepts/vlans'; import { ui } from 'support/ui'; import { linodeCreatePage } from 'support/ui/pages'; -import { chooseRegion } from 'support/util/regions'; import { randomIp, randomLabel, randomNumber, randomString, } from 'support/util/random'; -import { mockGetVLANs } from 'support/intercepts/vlans'; -import { mockCreateLinode } from 'support/intercepts/linodes'; +import { chooseRegion } from 'support/util/regions'; + +import { VLANFactory, linodeFactory, regionFactory } from 'src/factories'; describe('Create Linode with VLANs', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + linodeInterfaces: { enabled: false }, + }); + }); + /* * - Uses mock API data to confirm VLAN attachment UI flow during Linode create. * - Confirms that outgoing Linode create API request contains expected data for VLAN. @@ -29,11 +37,11 @@ describe('Create Linode with VLANs', () => { }); const mockVlan = VLANFactory.build({ + cidr_block: `${randomIp()}/24`, id: randomNumber(), label: randomLabel(), - region: mockLinodeRegion.id, - cidr_block: `${randomIp()}/24`, linodes: [], + region: mockLinodeRegion.id, }); mockGetVLANs([mockVlan]); @@ -67,11 +75,10 @@ describe('Create Linode with VLANs', () => { }); // Confirm that VLAN attachment is listed in summary, then create Linode. - cy.get('[data-qa-linode-create-summary]') - .scrollIntoView() - .within(() => { - cy.findByText('VLAN Attached').should('be.visible'); - }); + cy.get('[data-qa-linode-create-summary]').scrollIntoView(); + cy.get('[data-qa-linode-create-summary]').within(() => { + cy.findByText('VLAN Attached').should('be.visible'); + }); ui.button .findByTitle('Create Linode') @@ -117,11 +124,11 @@ describe('Create Linode with VLANs', () => { }); const mockVlan = VLANFactory.build({ + cidr_block: `${randomIp()}/24`, id: randomNumber(), label: randomLabel(), - region: mockLinodeRegion.id, - cidr_block: `${randomIp()}/24`, linodes: [], + region: mockLinodeRegion.id, }); mockGetVLANs([]); @@ -151,11 +158,10 @@ describe('Create Linode with VLANs', () => { }); // Confirm that VLAN attachment is listed in summary, then create Linode. - cy.get('[data-qa-linode-create-summary]') - .scrollIntoView() - .within(() => { - cy.findByText('VLAN Attached').should('be.visible'); - }); + cy.get('[data-qa-linode-create-summary]').scrollIntoView(); + cy.get('[data-qa-linode-create-summary]').within(() => { + cy.findByText('VLAN Attached').should('be.visible'); + }); ui.button .findByTitle('Create Linode') diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts index f558d3cea76..7cf424d3faf 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vpc.spec.ts @@ -1,12 +1,6 @@ -import { - linodeFactory, - regionFactory, - subnetFactory, - vpcFactory, - linodeConfigFactory, - LinodeConfigInterfaceFactoryWithVPC, -} from 'src/factories'; +import { linodeConfigInterfaceFactoryWithVPC } from '@linode/utilities'; import { mockGetLinodeConfigs } from 'support/intercepts/configs'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateLinode, mockGetLinodeDetails, @@ -29,9 +23,22 @@ import { randomString, } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; + +import { + linodeConfigFactory, + linodeFactory, + regionFactory, + subnetFactory, + vpcFactory, +} from 'src/factories'; import { WARNING_ICON_UNRECOMMENDED_CONFIG } from 'src/features/VPCs/constants'; describe('Create Linode with VPCs', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + linodeInterfaces: { enabled: false }, + }); + }); /* * - Confirms UI flow to create a Linode with an existing VPC assigned using mock API data. * - Confirms that VPC assignment is reflected in create summary section. @@ -43,9 +50,9 @@ describe('Create Linode with VPCs', () => { const mockSubnet = subnetFactory.build({ id: randomNumber(), + ipv4: `${randomIp()}/0`, label: randomLabel(), linodes: [], - ipv4: `${randomIp()}/0`, }); const mockVPC = vpcFactory.build({ @@ -61,11 +68,11 @@ describe('Create Linode with VPCs', () => { region: linodeRegion.id, }); - const mockInterface = LinodeConfigInterfaceFactoryWithVPC.build({ - vpc_id: mockVPC.id, - subnet_id: mockSubnet.id, - primary: true, + const mockInterface = linodeConfigInterfaceFactoryWithVPC.build({ active: true, + primary: true, + subnet_id: mockSubnet.id, + vpc_id: mockVPC.id, }); const mockLinodeConfig = linodeConfigFactory.build({ @@ -77,7 +84,7 @@ describe('Create Linode with VPCs', () => { linodes: [ { id: mockLinode.id, - interfaces: [{ id: mockInterface.id, active: true, config_id: 1 }], + interfaces: [{ active: true, config_id: 1, id: mockInterface.id }], }, ], }; @@ -96,7 +103,8 @@ describe('Create Linode with VPCs', () => { linodeCreatePage.setRootPassword(randomString(32)); // Confirm that mocked VPC is shown in the Autocomplete, and then select it. - cy.findByText('Assign VPC').click().type(mockVPC.label); + cy.findByText('Assign VPC').click(); + cy.focused().type(mockVPC.label); ui.autocompletePopper .findByTitle(mockVPC.label) @@ -110,11 +118,10 @@ describe('Create Linode with VPCs', () => { ); // Confirm VPC assignment indicator is shown in Linode summary. - cy.get('[data-qa-linode-create-summary]') - .scrollIntoView() - .within(() => { - cy.findByText('VPC Assigned').should('be.visible'); - }); + cy.get('[data-qa-linode-create-summary]').scrollIntoView(); + cy.get('[data-qa-linode-create-summary]').within(() => { + cy.findByText('VPC Assigned').should('be.visible'); + }); // Create Linode and confirm contents of outgoing API request payload. ui.button @@ -169,14 +176,14 @@ describe('Create Linode with VPCs', () => { const mockSubnet = subnetFactory.build({ id: randomNumber(), + ipv4: '10.0.0.0/24', label: randomLabel(), linodes: [], - ipv4: '10.0.0.0/24', }); const mockVPC = vpcFactory.build({ - id: randomNumber(), description: randomPhrase(), + id: randomNumber(), label: randomLabel(), region: linodeRegion.id, subnets: [mockSubnet], @@ -188,11 +195,11 @@ describe('Create Linode with VPCs', () => { region: linodeRegion.id, }); - const mockInterface = LinodeConfigInterfaceFactoryWithVPC.build({ - vpc_id: mockVPC.id, - subnet_id: mockSubnet.id, - primary: true, + const mockInterface = linodeConfigInterfaceFactoryWithVPC.build({ active: true, + primary: true, + subnet_id: mockSubnet.id, + vpc_id: mockVPC.id, }); const mockLinodeConfig = linodeConfigFactory.build({ @@ -204,7 +211,7 @@ describe('Create Linode with VPCs', () => { linodes: [ { id: mockLinode.id, - interfaces: [{ id: mockInterface.id, active: true, config_id: 1 }], + interfaces: [{ active: true, config_id: 1, id: mockInterface.id }], }, ], }; @@ -236,7 +243,8 @@ describe('Create Linode with VPCs', () => { vpcCreateDrawer.submit(); cy.wait('@createVpc'); - cy.findByText(mockErrorMessage).scrollIntoView().should('be.visible'); + cy.findByText(mockErrorMessage).scrollIntoView(); + cy.findByText(mockErrorMessage).should('be.visible'); // Create VPC with successful API response mocked. mockCreateVPC(mockVPC).as('createVpc'); @@ -336,10 +344,8 @@ describe('Create Linode with VPCs', () => { linodeCreatePage.selectRegionById(mockRegion.id); - cy.findByLabelText('Assign VPC') - .scrollIntoView() - .should('be.visible') - .should('be.disabled'); + cy.findByLabelText('Assign VPC').scrollIntoView(); + cy.findByLabelText('Assign VPC').should('be.visible').should('be.disabled'); cy.findByText(vpcNotAvailableMessage).should('be.visible'); }); diff --git a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts index 467fa122445..fb5bc50cea9 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -2,44 +2,56 @@ * @file Linode Create end-to-end tests. */ -import { ui } from 'support/ui'; -import { chooseRegion } from 'support/util/regions'; -import { randomLabel, randomString, randomNumber } from 'support/util/random'; -import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; -import { cleanUp } from 'support/util/cleanup'; -import { linodeCreatePage } from 'support/ui/pages'; +import { + linodeConfigInterfaceFactory, + linodeConfigInterfaceFactoryWithVPC, +} from '@linode/utilities'; import { authenticate } from 'support/api/authentication'; +import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetUser } from 'support/intercepts/account'; +import { mockGetLinodeConfigs } from 'support/intercepts/configs'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { interceptCreateLinode, - mockCreateLinodeError, mockCreateLinode, + mockCreateLinodeError, mockGetLinodeDisks, mockGetLinodeType, mockGetLinodeTypes, mockGetLinodeVolumes, } from 'support/intercepts/linodes'; import { interceptGetProfile } from 'support/intercepts/profile'; -import { Region, VLAN, Config, Disk } from '@linode/api-v4'; +import { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { mockGetVLANs } from 'support/intercepts/vlans'; +import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; +import { ui } from 'support/ui'; +import { linodeCreatePage } from 'support/ui/pages'; +import { cleanUp } from 'support/util/cleanup'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; import { getRegionById } from 'support/util/regions'; + import { + VLANFactory, accountFactory, - linodeFactory, + accountUserFactory, + grantsFactory, linodeConfigFactory, + linodeFactory, linodeTypeFactory, - VLANFactory, - vpcFactory, - subnetFactory, + profileFactory, regionFactory, - LinodeConfigInterfaceFactory, - LinodeConfigInterfaceFactoryWithVPC, + subnetFactory, + vpcFactory, } from 'src/factories'; -import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; -import { mockGetAccount } from 'support/intercepts/account'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { mockGetVLANs } from 'support/intercepts/vlans'; -import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; -import { mockGetLinodeConfigs } from 'support/intercepts/configs'; + +import type { Config, Disk, Region, VLAN } from '@linode/api-v4'; let username: string; @@ -49,6 +61,11 @@ describe('Create Linode', () => { cleanUp('linodes'); cleanUp('ssh-keys'); }); + beforeEach(() => { + mockAppendFeatureFlags({ + linodeInterfaces: { enabled: false }, + }); + }); /* * End-to-end tests to create Linodes for each available plan type. @@ -58,24 +75,24 @@ describe('Create Linode', () => { describe('By plan type', () => { [ { - planType: 'Shared CPU', - planLabel: 'Nanode 1 GB', planId: 'g6-nanode-1', + planLabel: 'Nanode 1 GB', + planType: 'Shared CPU', }, { - planType: 'Dedicated CPU', - planLabel: 'Dedicated 4 GB', planId: 'g6-dedicated-2', + planLabel: 'Dedicated 4 GB', + planType: 'Dedicated CPU', }, { - planType: 'High Memory', - planLabel: 'Linode 24 GB', planId: 'g7-highmem-1', + planLabel: 'Linode 24 GB', + planType: 'High Memory', }, { - planType: 'Premium CPU', - planLabel: 'Premium 4 GB', planId: 'g7-premium-2', + planLabel: 'Premium 4 GB', + planType: 'Premium CPU', }, // TODO Include GPU plan types. // TODO Include Accelerated plan types (when they're no longer as restricted) @@ -106,13 +123,12 @@ describe('Create Linode', () => { linodeCreatePage.setRootPassword(randomString(32)); // Confirm information in summary is shown as expected. - cy.get('[data-qa-linode-create-summary]') - .scrollIntoView() - .within(() => { - cy.findByText('Debian 12').should('be.visible'); - cy.findByText(linodeRegion.label).should('be.visible'); - cy.findByText(planConfig.planLabel).should('be.visible'); - }); + cy.get('[data-qa-linode-create-summary]').scrollIntoView(); + cy.get('[data-qa-linode-create-summary]').within(() => { + cy.findByText('Debian 12').should('be.visible'); + cy.findByText(linodeRegion.label).should('be.visible'); + cy.findByText(planConfig.planLabel).should('be.visible'); + }); // Create Linode and confirm it's provisioned as expected. ui.button @@ -183,9 +199,9 @@ describe('Create Linode', () => { }); const mockAcceleratedType = [ linodeTypeFactory.build({ + class: 'accelerated', id: 'accelerated-1', label: 'accelerated-1', - class: 'accelerated', }), ]; const mockRegions = [ @@ -229,13 +245,12 @@ describe('Create Linode', () => { linodeCreatePage.setRootPassword(randomString(32)); // Confirm information in summary is shown as expected. - cy.get('[data-qa-linode-create-summary]') - .scrollIntoView() - .within(() => { - cy.findByText('Debian 12').should('be.visible'); - cy.findByText(`US, ${linodeRegion.label}`).should('be.visible'); - cy.findByText(mockAcceleratedType[0].label).should('be.visible'); - }); + cy.get('[data-qa-linode-create-summary]').scrollIntoView(); + cy.get('[data-qa-linode-create-summary]').within(() => { + cy.findByText('Debian 12').should('be.visible'); + cy.findByText(`US, ${linodeRegion.label}`).should('be.visible'); + cy.findByText(mockAcceleratedType[0].label).should('be.visible'); + }); // Create Linode and confirm it's provisioned as expected. ui.button @@ -269,11 +284,11 @@ describe('Create Linode', () => { const rootpass = randomString(32); const sshPublicKeyLabel = randomLabel(); const randomKey = randomString(400, { - uppercase: true, lowercase: true, numbers: true, spaces: false, symbols: false, + uppercase: true, }); const sshPublicKey = `ssh-rsa e2etestkey${randomKey} e2etest@linode`; const linodeLabel = randomLabel(); @@ -295,19 +310,19 @@ describe('Create Linode', () => { subnets: [mockSubnet], }); const mockVPCRegion = regionFactory.build({ + capabilities: ['Linodes', 'VPCs', 'Vlans'], id: region.id, label: region.label, - capabilities: ['Linodes', 'VPCs', 'Vlans'], }); - const mockPublicConfigInterface = LinodeConfigInterfaceFactory.build({ + const mockPublicConfigInterface = linodeConfigInterfaceFactory.build({ ipam_address: null, purpose: 'public', }); - const mockVlanConfigInterface = LinodeConfigInterfaceFactory.build(); - const mockVpcConfigInterface = LinodeConfigInterfaceFactoryWithVPC.build({ - vpc_id: mockVPC.id, - purpose: 'vpc', + const mockVlanConfigInterface = linodeConfigInterfaceFactory.build(); + const mockVpcConfigInterface = linodeConfigInterfaceFactoryWithVPC.build({ active: true, + purpose: 'vpc', + vpc_id: mockVPC.id, }); const mockConfig: Config = linodeConfigFactory.build({ id: randomNumber(), @@ -320,22 +335,22 @@ describe('Create Linode', () => { }); const mockDisks: Disk[] = [ { - id: 44311273, - status: 'ready', - label: diskLabel, created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:30', filesystem: 'ext4', + id: 44311273, + label: diskLabel, size: 81408, + status: 'ready', + updated: '2020-08-21T17:26:30', }, { - id: 44311274, - status: 'ready', - label: '512 MB Swap Image', created: '2020-08-21T17:26:14', - updated: '2020-08-21T17:26:31', filesystem: 'swap', + id: 44311274, + label: '512 MB Swap Image', size: 512, + status: 'ready', + updated: '2020-08-21T17:26:31', }, ]; @@ -379,10 +394,8 @@ describe('Create Linode', () => { 'be.visible' ); // select VPC - cy.findByLabelText('Assign VPC') - .should('be.visible') - .focus() - .type(`${mockVPC.label}{downArrow}{enter}`); + cy.findByLabelText('Assign VPC').should('be.visible').focus(); + cy.focused().type(`${mockVPC.label}{downArrow}{enter}`); // select subnet cy.findByPlaceholderText('Select Subnet') .should('be.visible') @@ -399,10 +412,12 @@ describe('Create Linode', () => { .findByTitle('Add SSH Key') .should('be.visible') .within(() => { - cy.get('[id="label"]').clear().type(sshPublicKeyLabel); + cy.get('[id="label"]').clear(); + cy.focused().type(sshPublicKeyLabel); // An alert displays when the format of SSH key is incorrect - cy.get('[id="ssh-public-key"]').clear().type('WrongFormatSshKey'); + cy.get('[id="ssh-public-key"]').clear(); + cy.focused().type('WrongFormatSshKey'); ui.button .findByTitle('Add Key') .should('be.visible') @@ -413,7 +428,8 @@ describe('Create Linode', () => { ).should('be.visible'); // Create a new ssh key - cy.get('[id="ssh-public-key"]').clear().type(sshPublicKey); + cy.get('[id="ssh-public-key"]').clear(); + cy.focused().type(sshPublicKey); ui.button .findByTitle('Add Key') .should('be.visible') @@ -427,7 +443,9 @@ describe('Create Linode', () => { // When a user creates an SSH key, the list of SSH keys for each user updates to show the new key for the signed in user cy.findByText(sshPublicKeyLabel, { exact: false }).should('be.visible'); - cy.get('#linode-label').clear().type(linodeLabel).click(); + cy.get('#linode-label').clear(); + cy.focused().type(linodeLabel); + cy.focused().click(); cy.get('#root-password').type(rootpass); ui.button.findByTitle('Create Linode').click(); @@ -509,4 +527,62 @@ describe('Create Linode', () => { cy.findByText('You must select a Backup.').should('be.visible'); cy.findByText('Plan is required.').should('be.visible'); }); + + /* + * - Confirms UI flow when creating a Linode with a restricted user. + * - Confirms that a notice is shown informing the user they do not have permission to create a Linode. + * - Confirms that "Regions" field is disabled. + * - Confirms that "Linux Distribution" field is disabled. + * - Confirms that "Create Linode" button is disabled. + */ + it('should not allow restricted users to create linodes', () => { + // Mock setup for user profile, account user, and user grants with restricted permissions, + // simulating a default user without the ability to add Linodes. + const mockProfile = profileFactory.build({ + restricted: true, + username: randomLabel(), + }); + + const mockUser = accountUserFactory.build({ + restricted: true, + user_type: 'default', + username: mockProfile.username, + }); + + const mockGrants = grantsFactory.build({ + global: { + add_linodes: false, + }, + }); + + mockGetProfile(mockProfile); + mockGetProfileGrants(mockGrants); + mockGetUser(mockUser); + + // Login and wait for application to load + cy.visitWithLogin('/linodes/create'); + + // Confirm that a notice should be shown informing the user they do not have permission to create a Linode. + cy.findByText( + "You don't have permissions to create Linodes. Please contact your account administrator to request the necessary permissions." + ).should('be.visible'); + + // Confirm that "Region" select dropdown is disabled + ui.regionSelect.find().should('be.visible').should('be.disabled'); + + // Confirm that "Linux Distribution" select dropdown is disabled + cy.get('[data-qa-autocomplete="Linux Distribution"]').within(() => { + cy.get('[placeholder="Choose a Linux distribution"]') + .should('be.visible') + .should('be.disabled'); + + cy.get('[aria-label="Open"]').should('be.visible').should('be.disabled'); + }); + + // Confirm that "Create Linode" button is visible and disabled + ui.button + .findByTitle('Create Linode') + .should('be.visible') + .and('be.disabled'); + }); }); 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 1d869a24cc5..4668a9ed2e1 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -1,58 +1,61 @@ -import { createTestLinode } from 'support/util/linodes'; -import { ui } from 'support/ui'; +import { + linodeConfigInterfaceFactory, + linodeConfigInterfaceFactoryWithVPC, +} from '@linode/utilities'; +import { + VLANFactory, + kernelFactory, + linodeConfigFactory, + linodeFactory, + subnetFactory, + vpcFactory, +} from '@src/factories'; import { authenticate } from 'support/api/authentication'; -import { cleanUp } from 'support/util/cleanup'; -import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; import { LINODE_CLONE_TIMEOUT } from 'support/constants/linodes'; -import { chooseRegion, getRegionById } from 'support/util/regions'; -import { mockGetVLANs } from 'support/intercepts/vlans'; -import { - interceptRebootLinode, - mockGetLinodeDetails, - mockGetLinodeDisks, - mockGetLinodeKernels, - mockGetLinodeVolumes, - mockGetLinodeKernel, -} from 'support/intercepts/linodes'; import { - interceptGetLinodeConfigs, - interceptDeleteLinodeConfig, interceptCreateLinodeConfigs, + interceptDeleteLinodeConfig, + interceptGetLinodeConfigs, interceptUpdateLinodeConfigs, - mockGetLinodeConfigs, mockCreateLinodeConfigs, + mockGetLinodeConfigs, mockUpdateLinodeConfigs, } from 'support/intercepts/configs'; -import { fetchLinodeConfigs } from 'support/util/linodes'; import { - kernelFactory, - vpcFactory, - linodeFactory, - linodeConfigFactory, - VLANFactory, - LinodeConfigInterfaceFactory, - LinodeConfigInterfaceFactoryWithVPC, - subnetFactory, -} from '@src/factories'; -import { randomNumber, randomLabel, randomIp } from 'support/util/random'; + interceptRebootLinode, + mockGetLinodeDetails, + mockGetLinodeDisks, + mockGetLinodeKernel, + mockGetLinodeKernels, + mockGetLinodeVolumes, +} from 'support/intercepts/linodes'; +import { mockGetVLANs } from 'support/intercepts/vlans'; +import { mockGetVPC, mockGetVPCs } from 'support/intercepts/vpc'; +import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; import { fetchAllKernels, findKernelById } from 'support/util/kernels'; +import { fetchLinodeConfigs } from 'support/util/linodes'; +import { createTestLinode } from 'support/util/linodes'; +import { randomIp, randomLabel, randomNumber } from 'support/util/random'; +import { chooseRegion, getRegionById } from 'support/util/regions'; + import { LINODE_UNREACHABLE_HELPER_TEXT, NATTED_PUBLIC_IP_HELPER_TEXT, NOT_NATTED_HELPER_TEXT, } from 'src/features/VPCs/constants'; -import type { CreateTestLinodeOptions } from 'support/util/linodes'; import type { Config, CreateLinodeRequest, InterfacePurpose, + Kernel, Linode, - VLAN, Region, - Kernel, + VLAN, } from '@linode/api-v4'; +import type { CreateTestLinodeOptions } from 'support/util/linodes'; /** * Returns a Promise that resolves to a new test Linode and its first config object. @@ -255,7 +258,7 @@ describe('Linode Config management', () => { () => createLinodeAndGetConfig( { booted: true }, - { waitForBoot: true, securityMethod: 'vlan_no_internet' } + { securityMethod: 'vlan_no_internet', waitForBoot: true } ), 'Creating and booting test Linode' ).then(([linode, config]: [Linode, Config]) => { @@ -344,9 +347,10 @@ describe('Linode Config management', () => { .type(sharedConfigLabel); cy.findByText('Select a Kernel') - .scrollIntoView() - .click() - .type('Latest 64 bit{enter}'); + .as('qaSelectKernel') + .scrollIntoView(); + cy.get('@qaSelectKernel').click(); + cy.focused().type('Latest 64 bit{enter}'); ui.buttonGroup .findButtonByTitle('Add Configuration') @@ -464,19 +468,19 @@ describe('Linode Config management', () => { // Mock config with public internet for eth0 and VLAN for eth1. const mockConfig: Config = linodeConfigFactory.build({ id: randomNumber(), - label: randomLabel(), - kernel: mockKernel.id, interfaces: [ - LinodeConfigInterfaceFactory.build({ + linodeConfigInterfaceFactory.build({ ipam_address: null, - purpose: 'public', label: null, + purpose: 'public', }), - LinodeConfigInterfaceFactory.build({ + linodeConfigInterfaceFactory.build({ label: randomLabel(), purpose: 'vlan', }), ], + kernel: mockKernel.id, + label: randomLabel(), }); const mockVLANs: VLAN[] = VLANFactory.buildList(2); @@ -497,10 +501,10 @@ describe('Linode Config management', () => { const mockConfigWithVpc: Config = { ...mockConfig, interfaces: [ - LinodeConfigInterfaceFactoryWithVPC.build({ - vpc_id: mockVPC.id, + linodeConfigInterfaceFactoryWithVPC.build({ active: false, label: null, + vpc_id: mockVPC.id, }), ], }; @@ -542,9 +546,10 @@ describe('Linode Config management', () => { // Confirm that "VPC" can be selected for either "eth0", "eth1", or "eth2". // Add VPC to eth0 cy.get('[data-qa-textfield-label="eth0"]') - .scrollIntoView() - .click() - .type('VPC'); + .as('qaEth') + .scrollIntoView(); + cy.get('@qaEth').click(); + cy.focused().type('VPC'); ui.autocomplete.find().should('be.visible'); ui.autocompletePopper.findByTitle('VPC').should('be.visible').click(); @@ -556,9 +561,10 @@ describe('Linode Config management', () => { // Confirm that VPC is an option for eth1 and eth2, but don't select them. ['eth1', 'eth2'].forEach((interfaceName) => { cy.get(`[data-qa-textfield-label="${interfaceName}"]`) - .scrollIntoView() - .click() - .type('VPC'); + .as('qaInterfaceName') + .scrollIntoView(); + cy.get('@qaInterfaceName').click(); + cy.focused().type('VPC'); ui.autocomplete.find().should('be.visible'); ui.autocompletePopper @@ -608,10 +614,10 @@ describe('Linode Config management', () => { ...mockConfig, interfaces: [ ...mockConfigInterfaces, - LinodeConfigInterfaceFactoryWithVPC.build({ + linodeConfigInterfaceFactoryWithVPC.build({ + active: false, label: undefined, vpc_id: mockVPC.id, - active: false, }), ], }; @@ -653,10 +659,9 @@ describe('Linode Config management', () => { .should('be.visible') .within(() => { // Set eth2 to VPC and submit. - cy.get('[data-qa-textfield-label="eth2"]') - .scrollIntoView() - .click() - .type('VPC{enter}'); + cy.get('[data-qa-textfield-label="eth2"]').scrollIntoView(); + cy.get('[data-qa-textfield-label="eth2"]').click(); + cy.focused().type('VPC{enter}'); ui.button .findByTitle('Save Changes') @@ -695,9 +700,9 @@ describe('Linode Config management', () => { }); const mockSubnet = subnetFactory.build({ id: randomNumber(), + ipv4: `${randomIp()}/0`, label: randomLabel(), linodes: [], - ipv4: `${randomIp()}/0`, }); const mockVPC = vpcFactory.build({ id: randomNumber(), @@ -710,15 +715,15 @@ describe('Linode Config management', () => { const mockConfigWithVpc: Config = { ...mockConfig, interfaces: [ - LinodeConfigInterfaceFactory.build({ + linodeConfigInterfaceFactory.build({ ipam_address: null, - purpose: 'public', label: null, + purpose: 'public', }), - LinodeConfigInterfaceFactoryWithVPC.build({ - vpc_id: mockVPC.id, + linodeConfigInterfaceFactoryWithVPC.build({ active: false, label: null, + vpc_id: mockVPC.id, }), ], }; @@ -758,19 +763,17 @@ describe('Linode Config management', () => { cy.get('#label').type(`${mockConfigWithVpc.label}`); // Sets eth0 to "Public Internet", and sets eth1 to "VPC" - cy.get('[data-qa-textfield-label="eth0"]') - .scrollIntoView() - .click() - .type('Public Internet'); + cy.get('[data-qa-textfield-label="eth0"]').scrollIntoView(); + cy.get('[data-qa-textfield-label="eth0"]').click(); + cy.focused().type('Public Internet'); ui.autocomplete.find().should('be.visible'); ui.autocompletePopper .findByTitle('Public Internet') .should('be.visible') .click(); - cy.get('[data-qa-textfield-label="eth1"]') - .scrollIntoView() - .click() - .type('VPC'); + cy.get('[data-qa-textfield-label="eth1"]').scrollIntoView(); + cy.get('[data-qa-textfield-label="eth1"]').click(); + cy.focused().type('VPC'); ui.autocomplete.find().should('be.visible'); ui.autocompletePopper.findByTitle('VPC').should('be.visible').click(); // Confirm that internet access warning is displayed. @@ -778,19 +781,17 @@ describe('Linode Config management', () => { // Sets eth0 to "Public Internet", and sets eth1 to "VPC", // and checks "Assign a public IPv4 address for this Linode" - cy.get('[data-qa-textfield-label="VPC"]') - .scrollIntoView() - .click() - .type(`${mockVPC.label}`); + cy.get('[data-qa-textfield-label="VPC"]').scrollIntoView(); + cy.get('[data-qa-textfield-label="VPC"]').click(); + cy.focused().type(`${mockVPC.label}`); ui.autocomplete.find().should('be.visible'); ui.autocompletePopper .findByTitle(`${mockVPC.label}`) .should('be.visible') .click(); - cy.get('[data-qa-textfield-label="Subnet"]') - .scrollIntoView() - .click() - .type(`${mockSubnet.label}`); + cy.get('[data-qa-textfield-label="Subnet"]').scrollIntoView(); + cy.get('[data-qa-textfield-label="Subnet"]').click(); + cy.focused().type(`${mockSubnet.label}`); ui.autocomplete.find().should('be.visible'); ui.autocompletePopper .findByTitle(`${mockSubnet.label} (${mockSubnet.ipv4})`) @@ -800,13 +801,14 @@ describe('Linode Config management', () => { .should('be.visible') .click(); // Confirm that internet access warning is displayed. - cy.findByText(NATTED_PUBLIC_IP_HELPER_TEXT) - .scrollIntoView() - .should('be.visible'); + cy.findByText(NATTED_PUBLIC_IP_HELPER_TEXT).scrollIntoView(); + cy.findByText(NATTED_PUBLIC_IP_HELPER_TEXT).should('be.visible'); ui.buttonGroup .findButtonByTitle('Add Configuration') - .scrollIntoView() + .scrollIntoView(); + ui.buttonGroup + .findButtonByTitle('Add Configuration') .should('be.visible') .should('be.enabled') .click(); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts index 69169fb881d..9df12ab3fa8 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts @@ -1,23 +1,23 @@ import { - linodeFactory, - ipAddressFactory, - firewallFactory, firewallDeviceFactory, + firewallFactory, + ipAddressFactory, + linodeFactory, } from '@src/factories'; - -import type { IPRange } from '@linode/api-v4'; - +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockAddFirewallDevice, + mockGetFirewalls, +} from 'support/intercepts/firewalls'; import { mockGetLinodeDetails, - mockGetLinodeIPAddresses, mockGetLinodeFirewalls, + mockGetLinodeIPAddresses, } from 'support/intercepts/linodes'; import { mockUpdateIPAddress } from 'support/intercepts/networking'; import { ui } from 'support/ui'; -import { - mockAddFirewallDevice, - mockGetFirewalls, -} from 'support/intercepts/firewalls'; + +import type { IPRange } from '@linode/api-v4'; describe('IP Addresses', () => { const mockLinode = linodeFactory.build(); @@ -45,14 +45,17 @@ describe('IP Addresses', () => { }); beforeEach(() => { + mockAppendFeatureFlags({ + linodeInterfaces: { enabled: false }, + }); mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); mockGetLinodeFirewalls(mockLinode.id, []).as('getLinodeFirewalls'); mockGetLinodeIPAddresses(mockLinode.id, { ipv4: { - public: [ipAddress], private: [], - shared: [], + public: [ipAddress], reserved: [], + shared: [], }, ipv6: { global: [_ipv6Range], @@ -143,6 +146,12 @@ describe('IP Addresses', () => { }); describe('Firewalls', () => { + beforeEach(() => { + mockAppendFeatureFlags({ + linodeInterfaces: { enabled: false }, + }); + }); + it('allows the user to assign a Firewall from the Linode details page', () => { const linode = linodeFactory.build(); const firewalls = firewallFactory.buildList(3); @@ -212,7 +221,7 @@ describe('Firewalls', () => { cy.wait('@getLinodeFirewalls'); // Verify the firewall shows up in the table - cy.findByText(firewallToAttach.label) + cy.findAllByText(firewallToAttach.label) .should('be.visible') .closest('tr') .within(() => { 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 79268f5d555..e6ec268d9c0 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts @@ -1,15 +1,16 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; -import { Linode } from '@linode/api-v4'; import { authenticate } from 'support/api/authentication'; -import { createTestLinode } from 'support/util/linodes'; -import { ui } from 'support/ui'; -import { cleanUp } from 'support/util/cleanup'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { - interceptDeleteDisks, interceptAddDisks, + interceptDeleteDisks, interceptResizeDisks, } from 'support/intercepts/linodes'; +import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; + +import type { Linode } from '@linode/api-v4'; /** * Waits for a Linode to finish provisioning by checking the details page status indicator. @@ -117,7 +118,8 @@ const addDisk = (diskName: string, diskSize: number = DISK_CREATE_SIZE_MB) => { .should('be.visible') .within(() => { cy.findByLabelText('Label (required)').type(diskName); - cy.findByLabelText('Size (required)').clear().type(`${diskSize}`); + cy.findByLabelText('Size (required)').clear(); + cy.focused().type(`${diskSize}`); ui.button.findByTitle('Create').click(); }); @@ -235,9 +237,8 @@ describe('linode storage tab', () => { .findByTitle(`Resize ${diskName}`) .should('be.visible') .within(() => { - cy.findByLabelText('Size (required)') - .clear() - .type(`${DISK_RESIZE_SIZE_MB}`); + cy.findByLabelText('Size (required)').clear(); + cy.focused().type(`${DISK_RESIZE_SIZE_MB}`); ui.button.findByTitle('Resize').click(); }); diff --git a/packages/manager/cypress/e2e/core/linodes/migrate-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/migrate-linode.spec.ts index 9e00b502363..36abe9cc6b9 100644 --- a/packages/manager/cypress/e2e/core/linodes/migrate-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/migrate-linode.spec.ts @@ -1,21 +1,21 @@ +import { linodeDiskFactory } from '@src/factories'; +import { linodeFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; +import { + dcPricingCurrentPriceLabel, + dcPricingMockLinodeTypes, + dcPricingNewPriceLabel, +} from 'support/constants/dc-specific-pricing'; +import { mockGetLinodeDetails } from 'support/intercepts/linodes'; import { mockGetLinodeDisks, mockGetLinodeVolumes, mockMigrateLinode, } from 'support/intercepts/linodes'; +import { mockGetLinodeType } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; import { apiMatcher } from 'support/util/intercepts'; -import { linodeFactory } from '@src/factories'; -import { mockGetLinodeDetails } from 'support/intercepts/linodes'; import { getRegionById } from 'support/util/regions'; -import { - dcPricingMockLinodeTypes, - dcPricingCurrentPriceLabel, - dcPricingNewPriceLabel, -} from 'support/constants/dc-specific-pricing'; -import { mockGetLinodeType } from 'support/intercepts/linodes'; -import { linodeDiskFactory } from '@src/factories'; authenticate(); describe('Migrate linodes', () => { 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 30ecd433839..f4c539c1afd 100644 --- a/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/plan-selection.spec.ts @@ -1,20 +1,20 @@ // TODO: Cypress -// Move this to cypress component testing once the setup is complete - see https://github.com/linode/manager/pull/10134 -import { ui } from 'support/ui'; import { accountFactory, linodeTypeFactory, - regionFactory, regionAvailabilityFactory, + regionFactory, } from '@src/factories'; import { authenticate } from 'support/api/authentication'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetLinodeTypes } from 'support/intercepts/linodes'; import { - mockGetRegions, mockGetRegionAvailability, + mockGetRegions, } from 'support/intercepts/regions'; -import { mockGetLinodeTypes } from 'support/intercepts/linodes'; -import { mockGetAccount } from 'support/intercepts/account'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +// Move this to cypress component testing once the setup is complete - see https://github.com/linode/manager/pull/10134 +import { ui } from 'support/ui'; const mockRegions = [ regionFactory.build({ @@ -26,71 +26,71 @@ const mockRegions = [ const mockDedicatedLinodeTypes = [ linodeTypeFactory.build({ + class: 'dedicated', id: 'dedicated-1', label: 'dedicated-1', - class: 'dedicated', }), linodeTypeFactory.build({ + class: 'dedicated', id: 'dedicated-2', label: 'dedicated-2', - class: 'dedicated', }), linodeTypeFactory.build({ + class: 'dedicated', id: 'dedicated-3', label: 'dedicated-3', - class: 'dedicated', }), linodeTypeFactory.build({ + class: 'dedicated', id: 'dedicated-4', label: 'dedicated-4', - class: 'dedicated', }), ]; const mockSharedLinodeTypes = [ linodeTypeFactory.build({ + class: 'standard', id: 'shared-1', label: 'shared-1', - class: 'standard', }), linodeTypeFactory.build({ + class: 'standard', id: 'shared-2', label: 'shared-2', - class: 'standard', }), linodeTypeFactory.build({ + class: 'standard', id: 'shared-3', label: 'shared-3', - class: 'standard', }), ]; const mockHighMemoryLinodeTypes = [ linodeTypeFactory.build({ + class: 'highmem', id: 'highmem-1', label: 'highmem-1', - class: 'highmem', }), ]; const mockGPUType = [ linodeTypeFactory.build({ + class: 'gpu', id: 'gpu-1', label: 'gpu-1', - class: 'gpu', }), linodeTypeFactory.build({ + class: 'gpu', id: 'gpu-2', label: 'gpu-2 Ada', - class: 'gpu', }), ]; const mockAcceleratedType = [ linodeTypeFactory.build({ + class: 'accelerated', id: 'accelerated-1', label: 'accelerated-1', - class: 'accelerated', }), ]; @@ -104,23 +104,23 @@ const mockLinodeTypes = [ const mockRegionAvailability = [ regionAvailabilityFactory.build({ - plan: 'dedicated-3', available: false, + plan: 'dedicated-3', region: 'us-east', }), regionAvailabilityFactory.build({ - plan: 'dedicated-4', available: false, + plan: 'dedicated-4', region: 'us-east', }), regionAvailabilityFactory.build({ - plan: 'highmem-1', available: false, + plan: 'highmem-1', region: 'us-east', }), regionAvailabilityFactory.build({ - plan: 'shared-3', available: false, + plan: 'shared-3', region: 'us-east', }), ]; @@ -370,9 +370,9 @@ describe('displays specific linode plans for GPU', () => { ); mockAppendFeatureFlags({ gpuv2: { - transferBanner: true, - planDivider: true, egressBanner: true, + planDivider: true, + transferBanner: true, }, }).as('getFeatureFlags'); }); @@ -418,9 +418,9 @@ describe('displays specific kubernetes plans for GPU', () => { ); mockAppendFeatureFlags({ gpuv2: { - transferBanner: true, - planDivider: true, egressBanner: true, + planDivider: true, + transferBanner: true, }, }).as('getFeatureFlags'); }); diff --git a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts index 296ac8ae6b3..17bf313023c 100644 --- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts @@ -1,19 +1,31 @@ -import { CreateLinodeRequest, Linode } from '@linode/api-v4'; -import { ui } from 'support/ui'; -import { randomString, randomLabel } from 'support/util/random'; -import { authenticate } from 'support/api/authentication'; import { createStackScript } from '@linode/api-v4/lib'; -import { interceptGetStackScripts } from 'support/intercepts/stackscripts'; -import { createLinodeRequestFactory, linodeFactory } from '@src/factories'; -import { cleanUp } from 'support/util/cleanup'; -import { chooseRegion } from 'support/util/regions'; +import { + createLinodeRequestFactory, + imageFactory, + linodeFactory, + regionFactory, +} from '@src/factories'; +import { authenticate } from 'support/api/authentication'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; +import { mockGetAllImages, mockGetImage } from 'support/intercepts/images'; import { interceptRebuildLinode, mockGetLinodeDetails, + mockRebuildLinode, mockRebuildLinodeError, } from 'support/intercepts/linodes'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { + interceptGetStackScript, + interceptGetStackScripts, +} from 'support/intercepts/stackscripts'; +import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; -import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; +import { randomLabel, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +import type { CreateLinodeRequest, Linode } from '@linode/api-v4'; /** * Creates a Linode and StackScript. @@ -70,12 +82,10 @@ const findRebuildDialog = (linodeLabel: string) => { */ const assertPasswordComplexity = ( desiredPassword: string, - passwordStrength: 'Weak' | 'Fair' | 'Good' + passwordStrength: 'Fair' | 'Good' | 'Weak' ) => { - cy.findByLabelText('Root Password') - .should('be.visible') - .clear() - .type(desiredPassword); + cy.findByLabelText('Root Password').should('be.visible').clear(); + cy.focused().type(desiredPassword); cy.contains(`Strength: ${passwordStrength}`).should('be.visible'); }; @@ -87,15 +97,13 @@ const submitRebuild = () => { ui.button .findByTitle('Rebuild Linode') .scrollIntoView() - .should('have.attr', 'data-qa-form-data-loading', 'false') .should('be.visible') .should('be.enabled') .click(); }; // Error message that is displayed when desired password is not strong enough. -const passwordComplexityError = - 'Password does not meet complexity requirements.'; +const passwordComplexityError = 'Password does not meet strength requirement.'; authenticate(); describe('rebuild linode', () => { @@ -135,11 +143,11 @@ describe('rebuild linode', () => { findRebuildDialog(linode.label).within(() => { // "From Image" should be selected by default; no need to change the value. ui.autocomplete - .findByLabel('From Image') + .findByLabel('Rebuild From') .should('be.visible') - .should('have.value', 'From Image'); + .should('have.value', 'Image'); - ui.autocomplete.findByLabel('Images').should('be.visible').click(); + ui.autocomplete.findByLabel('Image').should('be.visible').click(); ui.autocompletePopper.findByTitle(image).should('be.visible').click(); // Type to confirm. @@ -169,7 +177,7 @@ describe('rebuild linode', () => { */ it('rebuilds a linode from Community StackScript', () => { cy.tag('method:e2e'); - const stackScriptId = '443929'; + const stackScriptId = 443929; const stackScriptName = 'OpenLiteSpeed-WordPress'; const image = 'AlmaLinux 9'; @@ -184,6 +192,7 @@ describe('rebuild linode', () => { ).then((linode: Linode) => { interceptRebuildLinode(linode.id).as('linodeRebuild'); interceptGetStackScripts().as('getStackScripts'); + interceptGetStackScript(stackScriptId).as('getStackScript'); cy.visitWithLogin(`/linodes/${linode.id}`); cy.findByText('RUNNING', { timeout: LINODE_CREATE_TIMEOUT }).should( 'be.visible' @@ -191,24 +200,28 @@ describe('rebuild linode', () => { openRebuildDialog(linode.label); findRebuildDialog(linode.label).within(() => { - ui.autocomplete.findByLabel('From Image').should('be.visible').click(); + ui.autocomplete + .findByLabel('Rebuild From') + .should('be.visible') + .click(); ui.autocompletePopper - .findByTitle('From Community StackScript') + .findByTitle('Community StackScript') .should('be.visible') .click(); cy.wait('@getStackScripts'); - cy.findByLabelText('Search by Label, Username, or Description') - .scrollIntoView() + cy.findByPlaceholderText('Search StackScripts').scrollIntoView(); + cy.findByPlaceholderText('Search StackScripts') .should('be.visible') - .type(`${stackScriptName}`); + .type(stackScriptName); cy.wait('@getStackScripts'); - cy.findByLabelText('List of StackScripts').within(() => { - cy.get(`[id="${stackScriptId}"][type="radio"]`).click(); - }); - ui.autocomplete.findByLabel('Images').should('be.visible').click(); + cy.get(`[id="stackscript-${stackScriptId}"]`).click(); + + cy.wait('@getStackScript'); + + ui.autocomplete.findByLabel('Image').should('be.visible').click(); ui.autocompletePopper.findByTitle(image).should('be.visible').click(); cy.findByLabelText('Linode Label') @@ -234,22 +247,22 @@ describe('rebuild linode', () => { // Create a StackScript to rebuild a Linode. const linodeRequest = createLinodeRequestFactory.build({ - label: randomLabel(), - region: region, image: 'linode/alpine3.18', + label: randomLabel(), + region, root_pass: randomString(16), }); const stackScriptRequest = { - label: randomLabel(), + deployments_active: 0, + deployments_total: 0, description: randomString(), - ordinal: 0, - logo_url: '', images: ['linode/alpine3.18'], - deployments_total: 0, - deployments_active: 0, is_public: false, + label: randomLabel(), + logo_url: '', mine: true, + ordinal: 0, rev_note: '', script: '#!/bin/bash\n\necho "Hello, world!"', user_defined_fields: [], @@ -267,22 +280,23 @@ describe('rebuild linode', () => { openRebuildDialog(linode.label); findRebuildDialog(linode.label).within(() => { - ui.autocomplete.findByLabel('From Image').should('be.visible').click(); + ui.autocomplete + .findByLabel('Rebuild From') + .should('be.visible') + .click(); ui.autocompletePopper - .findByTitle('From Account StackScript') + .findByTitle('Account StackScript') .should('be.visible') .click(); - cy.findByLabelText('Search by Label, Username, or Description') - .scrollIntoView() + cy.findByPlaceholderText('Search StackScripts').scrollIntoView(); + cy.findByPlaceholderText('Search StackScripts') .should('be.visible') .type(`${stackScript.label}`); - cy.findByLabelText('List of StackScripts').within(() => { - cy.get(`[id="${stackScript.id}"][type="radio"]`).click(); - }); + cy.get(`[id="stackscript-${stackScript.id}"]`).click(); - ui.autocomplete.findByLabel('Images').should('be.visible').click(); + ui.autocomplete.findByLabel('Image').should('be.visible').click(); ui.autocompletePopper.findByTitle(image).should('be.visible').click(); cy.findByLabelText('Linode Label') @@ -316,9 +330,9 @@ describe('rebuild linode', () => { cy.visitWithLogin(`/linodes/${mockLinode.id}?rebuild=true`); findRebuildDialog(mockLinode.label).within(() => { - ui.autocomplete.findByLabel('From Image').should('be.visible'); + ui.autocomplete.findByLabel('Rebuild From').should('be.visible'); ui.autocomplete - .findByLabel('Images') + .findByLabel('Image') .should('be.visible') .click() .type(image); @@ -326,14 +340,77 @@ describe('rebuild linode', () => { assertPasswordComplexity(rootPassword, 'Good'); - cy.findByLabelText('Linode Label') - .should('be.visible') - .click() - .type(mockLinode.label); + cy.findByLabelText('Linode Label').should('be.visible').click(); + cy.focused().type(mockLinode.label); submitRebuild(); cy.wait('@rebuildLinode'); cy.findByText(mockErrorMessage); }); }); + + it('can rebuild a Linode reusing existing user data', () => { + const region = regionFactory.build({ capabilities: ['Metadata'] }); + const linode = linodeFactory.build({ + region: region.id, + // has_user_data: true - add this when we add the type to make this test more realistic + }); + const image = imageFactory.build({ + capabilities: ['cloud-init'], + is_public: true, + }); + + mockRebuildLinode(linode.id, linode).as('rebuildLinode'); + mockGetLinodeDetails(linode.id, linode).as('getLinode'); + mockGetRegions([region]); + mockGetAllImages([image]); + mockGetImage(image.id, image); + + cy.visitWithLogin(`/linodes/${linode.id}?rebuild=true`); + + findRebuildDialog(linode.label).within(() => { + // Select an Image + ui.autocomplete.findByLabel('Image').should('be.visible').click(); + ui.autocompletePopper + .findByTitle(image.label, { exact: false }) + .should('be.visible') + .click(); + + // Type a root password + assertPasswordComplexity(rootPassword, 'Good'); + + // Open the User Data accordion + ui.accordionHeading.findByTitle('Add User Data').scrollIntoView().click(); + + // Verify the reuse checkbox is not checked by default and check it + cy.findByLabelText( + `Reuse user data previously provided for ${linode.label}` + ) + .should('not.be.checked') + .click(); + + // Verify the checkbox becomes checked + cy.findByLabelText( + `Reuse user data previously provided for ${linode.label}` + ).should('be.checked'); + + // Type to confirm + cy.findByLabelText('Linode Label').should('be.visible').click(); + cy.focused().type(linode.label); + + submitRebuild(); + }); + + cy.wait('@rebuildLinode').then((xhr) => { + // Confirm that metadata is NOT in the payload. + // If we omit metadata from the payload, the API will reuse previously provided userdata. + expect(xhr.request.body.metadata).to.be.undefined; + + // Verify other expected values are in the request + expect(xhr.request.body.image).to.equal(image.id); + expect(xhr.request.body.root_pass).to.be.a('string'); + }); + + ui.toast.assertMessage('Linode rebuild started.'); + }); }); diff --git a/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts index 636dc15a9c9..fed76acfa20 100644 --- a/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rescue-linode.spec.ts @@ -1,6 +1,6 @@ -import type { Linode } from '@linode/api-v4'; import { createLinodeRequestFactory, linodeFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { interceptGetLinodeDetails, interceptRebootLinodeIntoRescueMode, @@ -11,11 +11,12 @@ import { } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; -import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { createTestLinode } from 'support/util/linodes'; import { randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import type { Linode } from '@linode/api-v4'; + // Submits the Rescue Linode dialog, initiating reboot into rescue mode. const rebootInRescueMode = () => { ui.button 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 650805c76ec..c70b301fd7e 100644 --- a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts @@ -1,9 +1,9 @@ -import { createTestLinode } from 'support/util/linodes'; -import { ui } from 'support/ui'; -import { cleanUp } from 'support/util/cleanup'; import { authenticate } from 'support/api/authentication'; -import { interceptLinodeResize } from 'support/intercepts/linodes'; import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; +import { interceptLinodeResize } from 'support/intercepts/linodes'; +import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; authenticate(); describe('resize linode', () => { @@ -33,10 +33,8 @@ describe('resize linode', () => { cy.contains('Linode 8 GB').should('be.visible').click(); // Select warm resize option, and enter Linode label in type-to-confirm field. - cy.findByText('Warm resize') - .scrollIntoView() - .should('be.visible') - .click(); + cy.findByText('Warm resize').as('qaWarmResize').scrollIntoView(); + cy.get('@qaWarmResize').should('be.visible').click(); cy.findByLabelText('Linode Label').type(linode.label); @@ -75,10 +73,8 @@ describe('resize linode', () => { cy.contains('Linode 8 GB').should('be.visible').click(); - cy.findByText('Cold resize') - .scrollIntoView() - .should('be.visible') - .click(); + cy.findByText('Cold resize').as('qaColdResize').scrollIntoView(); + cy.get('@qaColdResize').should('be.visible').click(); cy.findByLabelText('Linode Label').type(linode.label); @@ -198,8 +194,9 @@ describe('resize linode', () => { cy.contains( 'The current disk size of your Linode is too large for the new service plan. Please resize your disk to accommodate the new plan. You can read our Resize Your Linode guide for more detailed instructions.' ) - .scrollIntoView() - .should('be.visible'); + .as('qaTheCurrentDisk') + .scrollIntoView(); + cy.get('@qaTheCurrentDisk').should('be.visible'); // Normal flow when resizing a linode to a smaller size after first resizing // its disk. @@ -239,7 +236,8 @@ describe('resize linode', () => { .within(() => { cy.contains('Size (required)').should('be.visible').click(); - cy.focused().clear().type(size); + cy.focused().clear(); + cy.focused().type(size); ui.buttonGroup .findButtonByTitle('Resize') diff --git a/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts b/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts index 5fa1ff3dda9..265617b56e1 100644 --- a/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/search-linodes.spec.ts @@ -1,7 +1,8 @@ +import { authenticate } from 'support/api/authentication'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; -import { authenticate } from 'support/api/authentication'; import { createTestLinode } from 'support/util/linodes'; + import type { Linode } from '@linode/api-v4'; authenticate(); diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts index 506bb09b592..2286564fb84 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts @@ -1,13 +1,14 @@ -import { authenticate } from 'support/api/authentication'; -import { createTestLinode } from 'support/util/linodes'; +import { accountSettingsFactory } from '@src/factories/accountSettings'; import { createLinodeRequestFactory } from '@src/factories/linodes'; +import { authenticate } from 'support/api/authentication'; +import { mockGetAccountSettings } from 'support/intercepts/account'; +import { interceptDeleteLinode } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; -import { Linode } from '@linode/api-v4'; -import { accountSettingsFactory } from '@src/factories/accountSettings'; +import { createTestLinode } from 'support/util/linodes'; import { randomLabel } from 'support/util/random'; -import { interceptDeleteLinode } from 'support/intercepts/linodes'; -import { mockGetAccountSettings } from 'support/intercepts/account'; + +import type { Linode } from '@linode/api-v4'; const confirmDeletion = (linodeLabel: string) => { cy.url().should('endWith', '/linodes'); @@ -31,10 +32,8 @@ const deleteLinodeFromActionMenu = (linodeLabel: string) => { .findByTitle(`Delete ${linodeLabel}?`) .should('be.visible') .within(() => { - cy.findByLabelText('Linode Label') - .should('be.visible') - .click() - .type(linodeLabel); + cy.findByLabelText('Linode Label').should('be.visible').click(); + cy.focused().type(linodeLabel); ui.buttonGroup .findButtonByTitle('Delete') @@ -48,14 +47,14 @@ const deleteLinodeFromActionMenu = (linodeLabel: string) => { }; const preferenceOverrides = { - linodes_view_style: 'list', - linodes_group_by_tag: false, - volumes_group_by_tag: false, desktop_sidebar_open: false, + linodes_group_by_tag: false, + linodes_view_style: 'list', sortKeys: { 'linodes-landing': { order: 'asc', orderBy: 'label' }, volume: { order: 'asc', orderBy: 'label' }, }, + volumes_group_by_tag: false, }; authenticate(); @@ -96,10 +95,8 @@ describe('delete linode', () => { .findByTitle(`Delete ${linode.label}?`) .should('be.visible') .within(() => { - cy.findByLabelText('Linode Label') - .should('be.visible') - .click() - .type(linode.label); + cy.findByLabelText('Linode Label').should('be.visible').click(); + cy.focused().type(linode.label); ui.buttonGroup .findButtonByTitle('Delete') @@ -147,10 +144,8 @@ describe('delete linode', () => { .findByTitle(`Delete ${linode.label}?`) .should('be.visible') .within(() => { - cy.findByLabelText('Linode Label') - .should('be.visible') - .click() - .type(linode.label); + cy.findByLabelText('Linode Label').should('be.visible').click(); + cy.focused().type(linode.label); ui.buttonGroup .findButtonByTitle('Delete') @@ -192,10 +187,8 @@ describe('delete linode', () => { .findByTitle(`Delete ${linode.label}?`) .should('be.visible') .within(() => { - cy.findByLabelText('Linode Label') - .should('be.visible') - .click() - .type(linode.label); + cy.findByLabelText('Linode Label').should('be.visible').click(); + cy.focused().type(linode.label); ui.buttonGroup .findButtonByTitle('Delete') diff --git a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts index 48d23cec3ff..ba4affc1c48 100644 --- a/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/smoke-linode-landing-table.spec.ts @@ -1,27 +1,32 @@ /* eslint-disable sonarjs/no-duplicate-string */ -import { Linode } from '@linode/api-v4'; +import { profileFactory, userPreferencesFactory } from '@src/factories'; import { accountSettingsFactory } from '@src/factories/accountSettings'; +import { accountUserFactory } from '@src/factories/accountUsers'; +import { grantsFactory } from '@src/factories/grants'; import { linodeFactory } from '@src/factories/linodes'; import { makeResourcePage } from '@src/mocks/serverHandlers'; -import { ui } from 'support/ui'; -import { routes } from 'support/ui/constants'; -import { apiMatcher } from 'support/util/intercepts'; -import { chooseRegion, getRegionById } from 'support/util/regions'; import { authenticate } from 'support/api/authentication'; -import { mockGetLinodes } from 'support/intercepts/linodes'; -import { userPreferencesFactory, profileFactory } from '@src/factories'; -import { accountUserFactory } from '@src/factories/accountUsers'; -import { grantsFactory } from '@src/factories/grants'; import { mockGetUser } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetLinodeFirewalls, + mockGetLinodes, +} from 'support/intercepts/linodes'; import { - mockGetUserPreferences, - mockUpdateUserPreferences, mockGetProfile, mockGetProfileGrants, + mockGetUserPreferences, + mockUpdateUserPreferences, } from 'support/intercepts/profile'; -import { randomLabel } from 'support/util/random'; +import { ui } from 'support/ui'; +import { routes } from 'support/ui/constants'; import * as commonLocators from 'support/ui/locators/common-locators'; import * as linodeLocators from 'support/ui/locators/linode-locators'; +import { apiMatcher } from 'support/util/intercepts'; +import { randomLabel } from 'support/util/random'; +import { chooseRegion, getRegionById } from 'support/util/regions'; + +import type { Linode } from '@linode/api-v4'; const mockLinodes = new Array(5).fill(null).map( (_item: null, index: number): Linode => { @@ -48,14 +53,14 @@ const linodeLabel = (index: number) => { }; const preferenceOverrides = { - linodes_view_style: 'list', - linodes_group_by_tag: false, - volumes_group_by_tag: false, desktop_sidebar_open: false, + linodes_group_by_tag: false, + linodes_view_style: 'list', sortKeys: { 'linodes-landing': { order: 'asc', orderBy: 'label' }, volume: { order: 'asc', orderBy: 'label' }, }, + volumes_group_by_tag: false, }; authenticate(); @@ -91,7 +96,8 @@ describe('linode landing checks', () => { cy.findByTestId('menu-item-Object Storage').should('be.visible'); cy.findByTestId('menu-item-Longview').should('be.visible'); cy.findByTestId('menu-item-Marketplace').should('be.visible'); - cy.findByTestId('menu-item-Account').scrollIntoView().should('be.visible'); + cy.findByTestId('menu-item-Account').scrollIntoView(); + cy.findByTestId('menu-item-Account').should('be.visible'); cy.findByTestId('menu-item-Help & Support').should('be.visible'); }); @@ -109,7 +115,10 @@ describe('linode landing checks', () => { ); ui.mainSearch.find().should('be.visible'); - cy.findByLabelText('Help & Support').should('be.enabled').click(); + cy.findByTestId('top-menu-help-and-support') + .should('be.visible') + .should('be.enabled') + .click(); cy.url().should('endWith', '/support'); cy.go('back'); @@ -392,6 +401,10 @@ describe('linode landing checks', () => { }); it('checks summary view for linode table', () => { + mockAppendFeatureFlags({ + linodeInterfaces: { enabled: false }, + }); + const mockPreferencesListView = userPreferencesFactory.build(); const mockPreferencesSummaryView = { @@ -405,6 +418,10 @@ describe('linode landing checks', () => { 'updateUserPreferences' ); + mockLinodes.forEach((linode) => { + mockGetLinodeFirewalls(linode.id, []); + }); + cy.visitWithLogin('/linodes'); cy.wait(['@getLinodes', '@getUserPreferences']); @@ -476,7 +493,7 @@ describe('linode landing checks for empty state', () => { .should('be.visible') .should('have.text', 'Cloud-based virtual machines'); - //Assert that recommended section is visible - Getting Started Guides, Deploy an App and Video Playlist + // Assert that recommended section is visible - Getting Started Guides, Deploy an App and Video Playlist cy.get('@resourcesSection') .contains('h2', 'Getting Started Guides') .should('be.visible'); @@ -509,14 +526,14 @@ describe('linode landing checks for empty state', () => { // Mock setup for user profile, account user, and user grants with restricted permissions, // simulating a default user without the ability to add Linodes. const mockProfile = profileFactory.build({ - username: randomLabel(), restricted: true, + username: randomLabel(), }); const mockUser = accountUserFactory.build({ - username: mockProfile.username, restricted: true, user_type: 'default', + username: mockProfile.username, }); const mockGrants = grantsFactory.build({ @@ -573,8 +590,8 @@ describe('linode landing checks for non-empty state with restricted user', () => // Mock setup for user profile, account user, and user grants with restricted permissions, // simulating a default user without the ability to add Linodes. const mockProfile = profileFactory.build({ - username: randomLabel(), restricted: true, + username: randomLabel(), }); const mockGrants = grantsFactory.build({ diff --git a/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts b/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts index 173a1fe4ec3..ae678525720 100644 --- a/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/switch-linode-state.spec.ts @@ -1,8 +1,9 @@ -import { ui } from 'support/ui'; import { authenticate } from 'support/api/authentication'; import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; +import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; + import type { Linode } from '@linode/api-v4'; authenticate(); diff --git a/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts b/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts index 648b6516da4..82e3eba1a0e 100644 --- a/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/update-linode-labels.spec.ts @@ -1,8 +1,8 @@ -import { createTestLinode } from 'support/util/linodes'; +import { authenticate } from 'support/api/authentication'; +import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; -import { LINODE_CREATE_TIMEOUT } from 'support/constants/linodes'; -import { authenticate } from 'support/api/authentication'; +import { createTestLinode } from 'support/util/linodes'; import { randomLabel } from 'support/util/random'; authenticate(); @@ -21,10 +21,9 @@ describe('update linode label', () => { ); cy.get(`[aria-label="Edit ${linode.label}"]`).click(); - cy.get(`[id="edit-${linode.label}-label"]`) - .click() - .clear() - .type(`${newLinodeLabel}{enter}`); + cy.get(`[id="edit-${linode.label}-label"]`).click(); + cy.focused().clear(); + cy.focused().type(`${newLinodeLabel}{enter}`); cy.visitWithLogin('/linodes'); cy.get(`[data-qa-linode="${newLinodeLabel}"]`).should('be.visible'); @@ -40,7 +39,9 @@ describe('update linode label', () => { ); cy.visitWithLogin(`/linodes/${linode.id}/settings`); - cy.get('[id="label"]').click().clear().type(`${newLinodeLabel}{enter}`); + cy.get('[id="label"]').click(); + cy.focused().clear(); + cy.focused().type(`${newLinodeLabel}{enter}`); ui.buttonGroup.findButtonByTitle('Save').should('be.visible').click(); cy.visitWithLogin('/linodes'); diff --git a/packages/manager/cypress/e2e/core/longview/longview-plan.spec.ts b/packages/manager/cypress/e2e/core/longview/longview-plan.spec.ts index def2a6a8b67..84977989f93 100644 --- a/packages/manager/cypress/e2e/core/longview/longview-plan.spec.ts +++ b/packages/manager/cypress/e2e/core/longview/longview-plan.spec.ts @@ -1,5 +1,3 @@ -import type { ActiveLongviewPlan } from '@linode/api-v4'; -import { longviewActivePlanFactory } from 'src/factories'; import { authenticate } from 'support/api/authentication'; import { mockGetLongviewPlan, @@ -8,6 +6,10 @@ import { import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; +import { longviewActivePlanFactory } from 'src/factories'; + +import type { ActiveLongviewPlan } from '@linode/api-v4'; + authenticate(); describe('longview plan', () => { before(() => { diff --git a/packages/manager/cypress/e2e/core/longview/longview.spec.ts b/packages/manager/cypress/e2e/core/longview/longview.spec.ts index ccd872b7aa7..e14eac42563 100644 --- a/packages/manager/cypress/e2e/core/longview/longview.spec.ts +++ b/packages/manager/cypress/e2e/core/longview/longview.spec.ts @@ -1,30 +1,32 @@ -import type { LongviewClient } from '@linode/api-v4'; import { DateTime } from 'luxon'; -import { - longviewResponseFactory, - longviewClientFactory, - longviewAppsFactory, - longviewLatestStatsFactory, - longviewPackageFactory, -} from 'src/factories'; import { authenticate } from 'support/api/authentication'; import { - longviewStatusTimeout, - longviewEmptyStateMessage, longviewAddClientButtonText, + longviewEmptyStateMessage, + longviewStatusTimeout, } from 'support/constants/longview'; import { interceptFetchLongviewStatus, - mockGetLongviewClients, - mockFetchLongviewStatus, mockCreateLongviewClient, mockDeleteLongviewClient, + mockFetchLongviewStatus, + mockGetLongviewClients, mockUpdateLongviewClient, } from 'support/intercepts/longview'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { randomLabel } from 'support/util/random'; +import { + longviewAppsFactory, + longviewClientFactory, + longviewLatestStatsFactory, + longviewPackageFactory, + longviewResponseFactory, +} from 'src/factories'; + +import type { LongviewClient } from '@linode/api-v4'; + /** * Returns the command used to install Longview which is shown in Cloud's UI. * @@ -321,7 +323,8 @@ describe('longview', () => { .click(); cy.get(`[data-qa-longview-client="${client.id}"]`).within(() => { - cy.get(`[data-testid="textfield-input"]`).clear().type(newClient.label); + cy.get(`[data-testid="textfield-input"]`).clear(); + cy.focused().type(newClient.label); cy.get(`[aria-label="Save new label"]`).should('be.visible').click(); }); diff --git a/packages/manager/cypress/e2e/core/managed/managed-contacts.spec.ts b/packages/manager/cypress/e2e/core/managed/managed-contacts.spec.ts index 51c0f37871f..95ad21d6feb 100644 --- a/packages/manager/cypress/e2e/core/managed/managed-contacts.spec.ts +++ b/packages/manager/cypress/e2e/core/managed/managed-contacts.spec.ts @@ -2,16 +2,17 @@ * @file Integration tests for Managed contacts. */ -import { contactFactory } from 'src/factories/managed'; import { visitUrlWithManagedEnabled } from 'support/api/managed'; import { - mockGetContacts, mockCreateContact, - mockUpdateContact, mockDeleteContact, + mockGetContacts, + mockUpdateContact, } from 'support/intercepts/managed'; import { ui } from 'support/ui'; -import { randomString, randomPhoneNumber } from 'support/util/random'; +import { randomPhoneNumber, randomString } from 'support/util/random'; + +import { contactFactory } from 'src/factories/managed'; // Message that's shown when there are no Managed contacts. const noContactsMessage = "You don't have any Contacts on your account."; @@ -25,9 +26,9 @@ describe('Managed Contacts tab', () => { const contactIds = [1, 2, 3, 4, 5]; const contacts = contactIds.map((id) => { return contactFactory.build({ - name: `Managed Contact ${id}`, email: `contact-email-${id}@example.com`, - id: id, + id, + name: `Managed Contact ${id}`, }); }); @@ -59,9 +60,9 @@ describe('Managed Contacts tab', () => { const contactPrimaryPhone = randomPhoneNumber(); const contactEmail = `${contactName}@example.com`; const contact = contactFactory.build({ + email: contactEmail, id: contactId, name: contactName, - email: contactEmail, phone: { primary: contactPrimaryPhone, secondary: null, @@ -86,18 +87,16 @@ describe('Managed Contacts tab', () => { .within(() => { cy.findByLabelText('Name', { exact: false }) .should('be.visible') - .click() - .type(contactName); + .click(); + cy.focused().type(contactName); cy.findByLabelText('E-mail', { exact: false }) .should('be.visible') - .click() - .type(contactEmail); + .click(); + cy.focused().type(contactEmail); - cy.findByLabelText('Primary Phone') - .should('be.visible') - .click() - .type(contactPrimaryPhone); + cy.findByLabelText('Primary Phone').should('be.visible').click(); + cy.focused().type(contactPrimaryPhone); ui.buttonGroup .findButtonByTitle('Add Contact') @@ -131,9 +130,9 @@ describe('Managed Contacts tab', () => { const contactNewPrimaryPhone = randomPhoneNumber(); const contact = contactFactory.build({ + email: contactOldEmail, id: contactId, name: contactOldName, - email: contactOldEmail, phone: { primary: contactOldPrimaryPhone, }, @@ -141,8 +140,8 @@ describe('Managed Contacts tab', () => { const updatedContact = { ...contact, - name: contactNewName, email: contactNewEmail, + name: contactNewName, phone: { ...contact.phone, primary: contactNewPrimaryPhone, @@ -173,21 +172,19 @@ describe('Managed Contacts tab', () => { .within(() => { cy.findByLabelText('Name', { exact: false }) .should('be.visible') - .click() - .clear() - .type(contactNewName); + .click(); + cy.focused().clear(); + cy.focused().type(contactNewName); cy.findByLabelText('E-mail', { exact: false }) .should('be.visible') - .click() - .clear() - .type(contactNewEmail); + .click(); + cy.focused().clear(); + cy.focused().type(contactNewEmail); - cy.findByLabelText('Primary Phone') - .should('be.visible') - .click() - .clear() - .type(contactNewPrimaryPhone); + cy.findByLabelText('Primary Phone').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(contactNewPrimaryPhone); ui.buttonGroup .findButtonByTitle('Save Changes') @@ -241,10 +238,8 @@ describe('Managed Contacts tab', () => { .findByTitle(`Delete Contact ${contactName}?`) .should('be.visible') .within(() => { - cy.findByLabelText('Contact Name:') - .should('be.visible') - .click() - .type(contactName); + cy.findByLabelText('Contact Name:').should('be.visible').click(); + cy.focused().type(contactName); ui.buttonGroup .findButtonByTitle('Delete Contact') diff --git a/packages/manager/cypress/e2e/core/managed/managed-credentials.spec.ts b/packages/manager/cypress/e2e/core/managed/managed-credentials.spec.ts index 9ce26179ad9..d0a068557d0 100644 --- a/packages/manager/cypress/e2e/core/managed/managed-credentials.spec.ts +++ b/packages/manager/cypress/e2e/core/managed/managed-credentials.spec.ts @@ -2,7 +2,6 @@ * @file Integration tests for Managed credentials. */ -import { credentialFactory } from 'src/factories/managed'; import { visitUrlWithManagedEnabled } from 'support/api/managed'; import { mockCreateCredential, @@ -14,6 +13,8 @@ import { import { ui } from 'support/ui'; import { randomLabel, randomString } from 'support/util/random'; +import { credentialFactory } from 'src/factories/managed'; + // Message that's shown when there are no Managed credentials. const noCredentialsMessage = "You don't have any Credentials on your account."; @@ -76,20 +77,16 @@ describe('Managed Credentials tab', () => { .findByTitle('Add Credential') .should('be.visible') .within(() => { - cy.findByLabelText('Label') - .should('be.visible') - .click() - .type(credentialLabel); + cy.findByLabelText('Label').should('be.visible').click(); + cy.focused().type(credentialLabel); cy.findByLabelText('Username', { exact: false }) .should('be.visible') - .click() - .type(credentialUsername); + .click(); + cy.focused().type(credentialUsername); - cy.findByLabelText('Password') - .should('be.visible') - .click() - .type(credentialPassword); + cy.findByLabelText('Password').should('be.visible').click(); + cy.focused().type(credentialPassword); ui.buttonGroup .findButtonByTitle('Add Credential') @@ -150,11 +147,9 @@ describe('Managed Credentials tab', () => { .should('be.visible') .within(() => { // Update label. - cy.findByLabelText('Label') - .should('be.visible') - .click() - .clear() - .type(credentialNewLabel); + cy.findByLabelText('Label').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(credentialNewLabel); ui.button .findByTitle('Update label') @@ -168,13 +163,11 @@ describe('Managed Credentials tab', () => { // Update credentials. cy.findByLabelText('Username', { exact: false }) .should('be.visible') - .click() - .type(randomString()); + .click(); + cy.focused().type(randomString()); - cy.findByLabelText('Password') - .should('be.visible') - .click() - .type(randomString()); + cy.findByLabelText('Password').should('be.visible').click(); + cy.focused().type(randomString()); ui.button .findByTitle('Update credentials') @@ -232,10 +225,8 @@ describe('Managed Credentials tab', () => { .findByTitle(`Delete Credential ${credentialLabel}?`) .should('be.visible') .within(() => { - cy.findByLabelText('Credential Name:') - .should('be.visible') - .click() - .type(credentialLabel); + cy.findByLabelText('Credential Name:').should('be.visible').click(); + cy.focused().type(credentialLabel); ui.buttonGroup .findButtonByTitle('Delete Credential') diff --git a/packages/manager/cypress/e2e/core/managed/managed-monitors.spec.ts b/packages/manager/cypress/e2e/core/managed/managed-monitors.spec.ts index 115042854d3..10149790052 100644 --- a/packages/manager/cypress/e2e/core/managed/managed-monitors.spec.ts +++ b/packages/manager/cypress/e2e/core/managed/managed-monitors.spec.ts @@ -2,7 +2,6 @@ * @file Integration tests for Managed monitors. */ -import { monitorFactory } from 'src/factories/managed'; import { visitUrlWithManagedEnabled } from 'support/api/managed'; import { mockCreateServiceMonitor, @@ -15,6 +14,8 @@ import { import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; +import { monitorFactory } from 'src/factories/managed'; + // Message that's shown when no Managed service monitors are set up. const noMonitorsMessage = "You don't have any Monitors on your account."; @@ -42,9 +43,9 @@ describe('Managed Monitors tab', () => { // Confirm that each monitor is listed and shows the correct status. [ - { label: 'OK Test Monitor', expectedStatus: 'Verified' }, - { label: 'Pending Test Monitor', expectedStatus: 'Pending' }, - { label: 'Problem Test Monitor', expectedStatus: 'Failed' }, + { expectedStatus: 'Verified', label: 'OK Test Monitor' }, + { expectedStatus: 'Pending', label: 'Pending Test Monitor' }, + { expectedStatus: 'Failed', label: 'Problem Test Monitor' }, ].forEach((monitorInfo) => { cy.findByText(monitorInfo.label) .should('be.visible') @@ -67,11 +68,11 @@ describe('Managed Monitors tab', () => { const monitorMenuLabel = 'Action menu for Monitor New Monitor'; const originalMonitor = monitorFactory.build({ - id: monitorId, body: '200', + credentials: [], + id: monitorId, label: originalLabel, status: 'ok', - credentials: [], }); const newMonitor = { @@ -94,11 +95,9 @@ describe('Managed Monitors tab', () => { .findByTitle('Edit Monitor') .should('be.visible') .within(() => { - cy.findByLabelText('Monitor Label') - .should('be.visible') - .click() - .clear() - .type(newLabel); + cy.findByLabelText('Monitor Label').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(newLabel); mockUpdateServiceMonitor(1, newMonitor).as('updateMonitor'); mockGetServiceMonitors([newMonitor]).as('getMonitors'); @@ -159,8 +158,8 @@ describe('Managed Monitors tab', () => { const monitorLabel = randomLabel(); const monitorUrl = 'https://www.example.com'; const newMonitor = monitorFactory.build({ - label: monitorLabel, address: monitorUrl, + label: monitorLabel, }); mockGetServiceMonitors([]).as('getMonitors'); @@ -183,14 +182,12 @@ describe('Managed Monitors tab', () => { .within(() => { cy.findByLabelText('Monitor Label', { exact: false }) .should('be.visible') - .click() - .type(monitorLabel); + .click(); + cy.focused().type(monitorLabel); // Can't `findByLabelText` because multiple elements with "URL" label exist. - cy.get('input[name="address"]') - .should('be.visible') - .click() - .type(monitorUrl); + cy.get('input[name="address"]').should('be.visible').click(); + cy.focused().type(monitorUrl); ui.buttonGroup .findButtonByTitle('Add Monitor') @@ -220,9 +217,9 @@ describe('Managed Monitors tab', () => { const monitorMenuLabel = `Action menu for Monitor ${monitorLabel}`; const originalMonitor = monitorFactory.build({ + address: monitorUrl, id: monitorId, label: monitorLabel, - address: monitorUrl, status: 'ok', }); @@ -249,10 +246,8 @@ describe('Managed Monitors tab', () => { .findByTitle(`Delete Monitor ${monitorLabel}?`) .should('be.visible') .within(() => { - cy.findByLabelText('Monitor Name:') - .should('be.visible') - .click() - .type(monitorLabel); + cy.findByLabelText('Monitor Name:').should('be.visible').click(); + cy.focused().type(monitorLabel); ui.buttonGroup .findButtonByTitle('Delete Monitor') diff --git a/packages/manager/cypress/e2e/core/managed/managed-navigation.spec.ts b/packages/manager/cypress/e2e/core/managed/managed-navigation.spec.ts index cc6f2ecefaa..9df4cefa15e 100644 --- a/packages/manager/cypress/e2e/core/managed/managed-navigation.spec.ts +++ b/packages/manager/cypress/e2e/core/managed/managed-navigation.spec.ts @@ -3,12 +3,11 @@ */ import { - contactFactory, - credentialFactory, - managedIssueFactory, - monitorFactory, -} from 'src/factories/managed'; -import { userPreferencesFactory } from 'src/factories/profile'; + managedAccount, + nonManagedAccount, + visitUrlWithManagedDisabled, + visitUrlWithManagedEnabled, +} from 'support/api/managed'; import { mockGetAccountSettings } from 'support/intercepts/account'; import { mockGetContacts, @@ -20,12 +19,15 @@ import { } from 'support/intercepts/managed'; import { mockGetUserPreferences } from 'support/intercepts/profile'; import { ui } from 'support/ui'; + import { - managedAccount, - nonManagedAccount, - visitUrlWithManagedDisabled, - visitUrlWithManagedEnabled, -} from 'support/api/managed'; + contactFactory, + credentialFactory, + managedIssueFactory, + monitorFactory, +} from 'src/factories/managed'; +import { userPreferencesFactory } from 'src/factories/profile'; + import type { UserPreferences } from '@linode/api-v4'; // Array of URLs to all Managed-related pages. diff --git a/packages/manager/cypress/e2e/core/managed/managed-ssh.spec.ts b/packages/manager/cypress/e2e/core/managed/managed-ssh.spec.ts index 1dd368a3702..2cf18f42d39 100644 --- a/packages/manager/cypress/e2e/core/managed/managed-ssh.spec.ts +++ b/packages/manager/cypress/e2e/core/managed/managed-ssh.spec.ts @@ -2,11 +2,6 @@ * @file Integration tests for Managed SSH access. */ -import type { ManagedLinodeSetting } from '@linode/api-v4'; -import { - managedLinodeSettingFactory, - managedSSHSettingFactory, -} from 'src/factories/managed'; import { visitUrlWithManagedEnabled } from 'support/api/managed'; import { mockGetLinodeSettings, @@ -21,6 +16,13 @@ import { randomString, } from 'support/util/random'; +import { + managedLinodeSettingFactory, + managedSSHSettingFactory, +} from 'src/factories/managed'; + +import type { ManagedLinodeSetting } from '@linode/api-v4'; + // Message that is shown when no Linodes are listed. const noLinodesMessage = "You don't have any Linodes on your account."; @@ -31,11 +33,11 @@ const noLinodesMessage = "You don't have any Linodes on your account."; */ const randomPublicSshKey = (): string => { const randomKey = randomString(400, { - uppercase: true, lowercase: true, numbers: true, spaces: false, symbols: false, + uppercase: true, }); return `ssh-rsa e2etestkey${randomKey} managedservices@linode`; @@ -96,9 +98,9 @@ describe('Managed SSH Access tab', () => { ...originalLinodeSettings, ssh: { ...originalLinodeSettings.ssh, - user: newUser, - port: newPort, ip: 'any', + port: newPort, + user: newUser, }, }; @@ -135,23 +137,17 @@ describe('Managed SSH Access tab', () => { .findByTitle(`Edit SSH Access for ${linodeLabel}`) .should('be.visible') .within(() => { - cy.findByLabelText('User Account') - .should('be.visible') - .click() - .clear() - .type(newUser); + cy.findByLabelText('User Account').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(newUser); // Set IP address to 'Any'. - cy.findByLabelText('IP Address') - .should('be.visible') - .click() - .type('Any{enter}'); + cy.findByLabelText('IP Address').should('be.visible').click(); + cy.focused().type('Any{enter}'); - cy.findByLabelText('Port') - .should('be.visible') - .click() - .clear() - .type(`${newPort}`); + cy.findByLabelText('Port').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(`${newPort}`); ui.button .findByTitle('Save Changes') diff --git a/packages/manager/cypress/e2e/core/managed/managed-summary.spec.ts b/packages/manager/cypress/e2e/core/managed/managed-summary.spec.ts index de7870de378..fa9293e9cac 100644 --- a/packages/manager/cypress/e2e/core/managed/managed-summary.spec.ts +++ b/packages/manager/cypress/e2e/core/managed/managed-summary.spec.ts @@ -1,10 +1,11 @@ +import { visitUrlWithManagedEnabled } from 'support/api/managed'; import { mockGetIssues, mockGetServiceMonitors, mockGetStats, } from 'support/intercepts/managed'; + import { managedIssueFactory, monitorFactory } from 'src/factories/managed'; -import { visitUrlWithManagedEnabled } from 'support/api/managed'; describe('Managed Summary tab', () => { /** diff --git a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-settings.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-settings.spec.ts index 3356f5b72f7..32675f582fe 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-settings.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancer-settings.spec.ts @@ -1,8 +1,3 @@ -import { - firewallDeviceFactory, - firewallFactory, - nodeBalancerFactory, -} from 'src/factories'; import { mockAddFirewallDevice, mockGetFirewalls, @@ -13,6 +8,12 @@ import { } from 'support/intercepts/nodebalancers'; import { ui } from 'support/ui'; +import { + firewallDeviceFactory, + firewallFactory, + nodeBalancerFactory, +} from 'src/factories'; + describe('Firewalls', () => { it('allows the user to assign a Firewall from the NodeBalancer settings page', () => { const nodebalancer = nodeBalancerFactory.build(); diff --git a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts index 1856008d777..0579597a751 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts @@ -1,13 +1,15 @@ +import { authenticate } from 'support/api/authentication'; import { entityTag } from 'support/constants/cypress'; +import { interceptCreateNodeBalancer } from 'support/intercepts/nodebalancers'; +import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; import { randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { ui } from 'support/ui'; -import { cleanUp } from 'support/util/cleanup'; -import { authenticate } from 'support/api/authentication'; -import type { Linode } from '@linode/api-v4'; + import { nodeBalancerFactory } from 'src/factories'; -import { interceptCreateNodeBalancer } from 'support/intercepts/nodebalancers'; + +import type { Linode } from '@linode/api-v4'; authenticate(); describe('create NodeBalancer to test the submission of multiple nodes and multiple configs', () => { @@ -22,14 +24,14 @@ describe('create NodeBalancer to test the submission of multiple nodes and multi it('creates a NodeBalancer with multiple Backend Nodes', () => { const region = chooseRegion(); const linodePayload = { - region: region.id, // NodeBalancers require Linodes with private IPs. private_ip: true, + region: region.id, }; const linodePayload_2 = { - region: region.id, private_ip: true, + region: region.id, }; const createTestLinodes = async () => { @@ -42,55 +44,47 @@ describe('create NodeBalancer to test the submission of multiple nodes and multi cy.defer(createTestLinodes, 'Creating 2 test Linodes').then( ([linode, linode2]: [Linode, Linode]) => { const nodeBal = nodeBalancerFactory.build({ + ipv4: linode.ipv4[1], label: randomLabel(), region: region.id, - ipv4: linode.ipv4[1], }); const nodeBal_2 = nodeBalancerFactory.build({ + ipv4: linode2.ipv4[1], label: randomLabel(), region: region.id, - ipv4: linode2.ipv4[1], }); interceptCreateNodeBalancer().as('createNodeBalancer'); cy.visitWithLogin('/nodebalancers/create'); - cy.get('[id="nodebalancer-label"]') - .should('be.visible') - .click() - .clear() - .type(nodeBal.label); - cy.findByPlaceholderText(/create a tag/i) - .click() - .type(entityTag); + cy.get('[id="nodebalancer-label"]').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(nodeBal.label); + cy.findByPlaceholderText(/create a tag/i).click(); + cy.focused().type(entityTag); // this will create the NB in newark, where the default Linode was created ui.regionSelect.find().click().clear().type(`${region.label}{enter}`); // node backend config - cy.findByText('Label').click().type(randomLabel()); - cy.findByLabelText('IP Address') - .should('be.visible') - .click() - .type(nodeBal.ipv4); + cy.findByText('Label').click(); + cy.focused().type(randomLabel()); + cy.findByLabelText('IP Address').should('be.visible').click(); + cy.focused().type(nodeBal.ipv4); ui.autocompletePopper .findByTitle(nodeBal.ipv4) .should('be.visible') .click(); - cy.findByLabelText('Weight') - .should('be.visible') - .click() - .clear() - .type('50'); + cy.findByLabelText('Weight').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type('50'); // Add a backend node cy.get('[data-testid="button"]').contains('Add a Node').click(); - cy.findAllByText('Label').last().click().type(randomLabel()); - cy.findAllByText('IP Address') - .last() - .should('be.visible') - .click() - .type(nodeBal_2.ipv4); + cy.findAllByText('Label').last().click(); + cy.focused().type(randomLabel()); + cy.findAllByText('IP Address').last().should('be.visible').click(); + cy.focused().type(nodeBal_2.ipv4); ui.autocompletePopper .findByTitle(nodeBal_2.ipv4) .should('be.visible') @@ -98,9 +92,9 @@ describe('create NodeBalancer to test the submission of multiple nodes and multi cy.get('[data-testid="textfield-input"]') .last() .should('be.visible') - .click() - .clear() - .type('50'); + .click(); + cy.focused().clear(); + cy.focused().type('50'); // Confirm Summary info cy.get('[data-qa-summary="true"]').within(() => { @@ -122,14 +116,14 @@ describe('create NodeBalancer to test the submission of multiple nodes and multi it('creates a NodeBalancer with an additional config', () => { const region = chooseRegion(); const linodePayload = { - region: region.id, // NodeBalancers require Linodes with private IPs. private_ip: true, + region: region.id, }; const linodePayload_2 = { - region: region.id, private_ip: true, + region: region.id, }; const createTestLinodes = async () => { @@ -142,37 +136,33 @@ describe('create NodeBalancer to test the submission of multiple nodes and multi cy.defer(createTestLinodes, 'Creating 2 test Linodes').then( ([linode, linode2]: [Linode, Linode]) => { const nodeBal = nodeBalancerFactory.build({ + ipv4: linode.ipv4[1], label: randomLabel(), region: region.id, - ipv4: linode.ipv4[1], }); const nodeBal_2 = nodeBalancerFactory.build({ + ipv4: linode2.ipv4[1], label: randomLabel(), region: region.id, - ipv4: linode2.ipv4[1], }); interceptCreateNodeBalancer().as('createNodeBalancer'); cy.visitWithLogin('/nodebalancers/create'); - cy.get('[id="nodebalancer-label"]') - .should('be.visible') - .click() - .clear() - .type(nodeBal.label); - cy.findByPlaceholderText(/create a tag/i) - .click() - .type(entityTag); + cy.get('[id="nodebalancer-label"]').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(nodeBal.label); + cy.findByPlaceholderText(/create a tag/i).click(); + cy.focused().type(entityTag); // This will create the NB in newark, where the default Linode was created ui.regionSelect.find().click().clear().type(`${region.label}{enter}`); // Node backend config - cy.findByText('Label').click().type(randomLabel()); - cy.findByLabelText('IP Address') - .should('be.visible') - .click() - .type(nodeBal.ipv4); + cy.findByText('Label').click(); + cy.focused().type(randomLabel()); + cy.findByLabelText('IP Address').should('be.visible').click(); + cy.focused().type(nodeBal.ipv4); ui.autocompletePopper .findByTitle(nodeBal.ipv4) .should('be.visible') @@ -183,17 +173,13 @@ describe('create NodeBalancer to test the submission of multiple nodes and multi .contains('Add another Configuration') .click(); cy.get('[data-qa-panel="Configuration - Port "]').within(() => { - cy.get('[data-testid="textfield-input"]') - .first() - .click() - .type('8080'); + cy.get('[data-testid="textfield-input"]').first().click(); + cy.focused().type('8080'); }); - cy.findAllByText('Label').last().click().type(randomLabel()); - cy.findAllByText('IP Address') - .last() - .should('be.visible') - .click() - .type(nodeBal_2.ipv4); + cy.findAllByText('Label').last().click(); + cy.focused().type(randomLabel()); + cy.findAllByText('IP Address').last().should('be.visible').click(); + cy.focused().type(nodeBal_2.ipv4); ui.autocompletePopper .findByTitle(nodeBal_2.ipv4) .should('be.visible') @@ -220,9 +206,9 @@ describe('create NodeBalancer to test the submission of multiple nodes and multi it('displays errors during adding new config', () => { const region = chooseRegion(); const linodePayload = { - region: region.id, // NodeBalancers require Linodes with private IPs. private_ip: true, + region: region.id, }; cy.defer( @@ -230,30 +216,26 @@ describe('create NodeBalancer to test the submission of multiple nodes and multi 'Creating test Linode' ).then((linode: Linode) => { const nodeBal = nodeBalancerFactory.build({ + ipv4: linode.ipv4[1], label: randomLabel(), region: region.id, - ipv4: linode.ipv4[1], }); cy.visitWithLogin('/nodebalancers/create'); - cy.get('[id="nodebalancer-label"]') - .should('be.visible') - .click() - .clear() - .type(nodeBal.label); - cy.findByPlaceholderText(/create a tag/i) - .click() - .type(entityTag); + cy.get('[id="nodebalancer-label"]').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(nodeBal.label); + cy.findByPlaceholderText(/create a tag/i).click(); + cy.focused().type(entityTag); // This will create the NB in newark, where the default Linode was created ui.regionSelect.find().click().clear().type(`${region.label}{enter}`); // Node backend config - cy.findByText('Label').click().type(randomLabel()); - cy.findByLabelText('IP Address') - .should('be.visible') - .click() - .type(nodeBal.ipv4); + cy.findByText('Label').click(); + cy.focused().type(randomLabel()); + cy.findByLabelText('IP Address').should('be.visible').click(); + cy.focused().type(nodeBal.ipv4); ui.autocompletePopper .findByTitle(nodeBal.ipv4) .should('be.visible') @@ -264,16 +246,20 @@ describe('create NodeBalancer to test the submission of multiple nodes and multi .contains('Add another Configuration') .click(); cy.get('[data-qa-panel="Configuration - Port "]').within(() => { - cy.get('[data-testid="textfield-input"]').first().click().type('80'); + cy.get('[data-testid="textfield-input"]').first().click(); + cy.focused().type('80'); }); cy.get('[data-qa-deploy-nodebalancer]').click(); // Confirm error displays - cy.contains('Port must be unique').scrollIntoView().should('be.visible'); - cy.contains('Label is required').scrollIntoView().should('be.visible'); + cy.contains('Port must be unique').as('qaPort').scrollIntoView(); + cy.get('@qaPort').should('be.visible'); + cy.contains('Label is required').as('qaLabelIs').scrollIntoView(); + cy.get('@qaLabelIs').should('be.visible'); cy.contains('Must be a valid private IPv4 address.') - .scrollIntoView() - .should('be.visible'); + .as('qaMustbe') + .scrollIntoView(); + cy.get('@qaMustbe').should('be.visible'); }); }); }); diff --git a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-empty-landing-page.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-empty-landing-page.spec.ts index b93401cab6c..15ebe82c551 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-empty-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-empty-landing-page.spec.ts @@ -1,5 +1,5 @@ -import { ui } from 'support/ui'; import { mockGetNodeBalancers } from 'support/intercepts/nodebalancers'; +import { ui } from 'support/ui'; describe('NodeBalancers empty landing page', () => { /** diff --git a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts index 002eb96f629..12471265c8d 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts @@ -1,15 +1,14 @@ +import { authenticate } from 'support/api/authentication'; import { entityTag } from 'support/constants/cypress'; -import { createTestLinode } from 'support/util/linodes'; - -import { randomLabel } from 'support/util/random'; -import { chooseRegion, getRegionById } from 'support/util/regions'; import { dcPricingDocsLabel, dcPricingDocsUrl, } from 'support/constants/dc-specific-pricing'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; -import { authenticate } from 'support/api/authentication'; +import { createTestLinode } from 'support/util/linodes'; +import { randomLabel } from 'support/util/random'; +import { chooseRegion, getRegionById } from 'support/util/regions'; import type { NodeBalancer } from '@linode/api-v4'; @@ -17,14 +16,15 @@ const deployNodeBalancer = () => { cy.get('[data-qa-deploy-nodebalancer]').click(); }; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { interceptCreateNodeBalancer } from 'support/intercepts/nodebalancers'; +import { mockGetRegions } from 'support/intercepts/regions'; + import { linodeFactory, nodeBalancerFactory, regionFactory, } from 'src/factories'; -import { interceptCreateNodeBalancer } from 'support/intercepts/nodebalancers'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { mockGetLinodes } from 'support/intercepts/linodes'; const createNodeBalancerWithUI = ( nodeBal: NodeBalancer, @@ -33,14 +33,11 @@ const createNodeBalancerWithUI = ( const regionName = getRegionById(nodeBal.region).label; cy.visitWithLogin('/nodebalancers/create'); - cy.get('[id="nodebalancer-label"]') - .should('be.visible') - .click() - .clear() - .type(nodeBal.label); - cy.findByPlaceholderText(/create a tag/i) - .click() - .type(entityTag); + cy.get('[id="nodebalancer-label"]').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(nodeBal.label); + cy.findByPlaceholderText(/create a tag/i).click(); + cy.focused().type(entityTag); if (isDcPricingTest) { const newRegion = getRegionById('br-gru'); @@ -71,12 +68,11 @@ const createNodeBalancerWithUI = ( ui.regionSelect.find().click().clear().type(`${regionName}{enter}`); // node backend config - cy.findByText('Label').click().type(randomLabel()); + cy.findByText('Label').click(); + cy.focused().type(randomLabel()); - cy.findByLabelText('IP Address') - .should('be.visible') - .click() - .type(nodeBal.ipv4); + cy.findByLabelText('IP Address').should('be.visible').click(); + cy.focused().type(nodeBal.ipv4); ui.autocompletePopper.findByTitle(nodeBal.ipv4).should('be.visible').click(); @@ -95,16 +91,16 @@ describe('create NodeBalancer', () => { it('creates a NodeBalancer in a region with base pricing', () => { const region = chooseRegion(); const linodePayload = { - region: region.id, // NodeBalancers require Linodes with private IPs. private_ip: true, + region: region.id, }; cy.defer(() => createTestLinode(linodePayload)).then((linode) => { const nodeBal = nodeBalancerFactory.build({ + ipv4: linode.ipv4[1], label: randomLabel(), region: region.id, - ipv4: linode.ipv4[1], }); // catch request interceptCreateNodeBalancer().as('createNodeBalancer'); @@ -144,7 +140,8 @@ describe('create NodeBalancer', () => { cy.findByLabelText('Label').type('my-node-1'); - cy.findByLabelText('IP Address').click().type(linode.ipv4[0]); + cy.findByLabelText('IP Address').click(); + cy.focused().type(linode.ipv4[0]); ui.autocompletePopper.findByTitle(linode.label).click(); @@ -177,15 +174,15 @@ describe('create NodeBalancer', () => { it('shows DC-specific pricing information when creating a NodeBalancer', () => { const initialRegion = getRegionById('us-west'); const linodePayload = { - region: initialRegion.id, // NodeBalancers require Linodes with private IPs. private_ip: true, + region: initialRegion.id, }; cy.defer(() => createTestLinode(linodePayload)).then((linode) => { const nodeBal = nodeBalancerFactory.build({ + ipv4: linode.ipv4[1], label: randomLabel(), region: initialRegion.id, - ipv4: linode.ipv4[1], }); // catch request diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts index 47de9225d8a..4bcf0b1f6ed 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-fetching.spec.ts @@ -2,11 +2,13 @@ * @file Integration tests for Cloud Manager's events fetching and polling behavior. */ -import { mockGetEvents, mockGetEventsPolling } from 'support/intercepts/events'; import { DateTime } from 'luxon'; -import { eventFactory } from 'src/factories'; -import { randomNumber } from 'support/util/random'; +import { mockGetEvents, mockGetEventsPolling } from 'support/intercepts/events'; import { mockGetVolumes } from 'support/intercepts/volumes'; +import { randomNumber } from 'support/util/random'; + +import { eventFactory } from 'src/factories'; + import type { Interception } from 'support/cypress-exports'; describe('Event fetching and polling', () => { @@ -52,15 +54,15 @@ describe('Event fetching and polling', () => { */ it('Polls events endpoint after initial fetch', () => { const mockEvent = eventFactory.build({ - id: randomNumber(10000, 99999), created: DateTime.now() .minus({ minutes: 5 }) .toUTC() .startOf('second') // Helps with matching the timestamp at the start of the second .toFormat("yyyy-MM-dd'T'HH:mm:ss"), duration: null, - rate: null, + id: randomNumber(10000, 99999), percent_complete: null, + rate: null, }); mockGetEvents([mockEvent]).as('getEvents'); @@ -109,13 +111,13 @@ describe('Event fetching and polling', () => { .toFormat("yyyy-MM-dd'T'HH:mm:ss"); const mockEvent = eventFactory.build({ - id: randomNumber(10000, 99999), created: DateTime.now() .minus({ minutes: 5 }) .toFormat("yyyy-MM-dd'T'HH:mm:ss"), duration: null, - rate: null, + id: randomNumber(10000, 99999), percent_complete: null, + rate: null, }); mockGetEvents([mockEvent]).as('getEventsInitialFetches'); @@ -164,22 +166,22 @@ describe('Event fetching and polling', () => { .toFormat("yyyy-MM-dd'T'HH:mm:ss"); const mockEventBasic = eventFactory.build({ - id: randomNumber(10000, 99999), created: DateTime.now() .minus({ minutes: 5 }) .startOf('second') // Helps with matching the timestamp at the start of the second .toFormat("yyyy-MM-dd'T'HH:mm:ss"), duration: null, - rate: null, + id: randomNumber(10000, 99999), percent_complete: null, + rate: null, }); const mockEventInProgress = eventFactory.build({ - id: randomNumber(10000, 99999), created: DateTime.now().minus({ minutes: 6 }).toISO(), duration: 0, - rate: null, + id: randomNumber(10000, 99999), percent_complete: 50, + rate: null, }); const mockEvents = [mockEventBasic, mockEventInProgress]; diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/events-menu.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-menu.spec.ts index 79c1638848f..6f6239455fb 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/events-menu.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-menu.spec.ts @@ -2,13 +2,14 @@ * @file Integration tests for Cloud Manager's events menu. */ +import { DateTime } from 'luxon'; import { mockGetEvents, mockMarkEventSeen } from 'support/intercepts/events'; import { ui } from 'support/ui'; -import { eventFactory } from 'src/factories'; import { buildArray } from 'support/util/arrays'; -import { DateTime } from 'luxon'; import { randomLabel, randomNumber } from 'support/util/random'; +import { eventFactory } from 'src/factories'; + describe('Notifications Menu', () => { /* * - Confirms that the notification menu shows all events when 20 or fewer exist. @@ -19,17 +20,17 @@ describe('Notifications Menu', () => { action: 'linode_delete', // The response from the API will be ordered by created date, descending. created: DateTime.local().minus({ minutes: index }).toISO(), - percent_complete: null, - rate: null, - seen: false, duration: null, - status: 'scheduled', entity: { id: 1000 + index, label: `my-linode-${index}`, type: 'linode', url: `/v4/linode/instances/${1000 + index}`, }, + percent_complete: null, + rate: null, + seen: false, + status: 'scheduled', username: randomLabel(), }); }); @@ -51,8 +52,9 @@ describe('Notifications Menu', () => { // Confirm that all mocked events are shown in the notification menu. mockEvents.forEach((event) => { cy.get(`[data-qa-event="${event.id}"]`) - .scrollIntoView() - .should('be.visible'); + .as('qaEventId') + .scrollIntoView(); + cy.get('@qaEventId').should('be.visible'); }); }); }); @@ -67,17 +69,17 @@ describe('Notifications Menu', () => { action: 'linode_delete', // The response from the API will be ordered by created date, descending. created: DateTime.local().minus({ minutes: index }).toISO(), - percent_complete: null, - rate: null, - seen: false, duration: null, - status: 'scheduled', entity: { id: 1000 + index, label: `my-linode-${index}`, type: 'linode', url: `/v4/linode/instances/${1000 + index}`, }, + percent_complete: null, + rate: null, + seen: false, + status: 'scheduled', username: randomLabel(), }); }); @@ -102,8 +104,9 @@ describe('Notifications Menu', () => { // Confirm that first 20 events in response are displayed. shownEvents.forEach((event) => { cy.get(`[data-qa-event="${event.id}"]`) - .scrollIntoView() - .should('be.visible'); + .as('qaEventId') + .scrollIntoView(); + cy.get('@qaEventId').should('be.visible'); }); // Confirm that last 5 events in response are not displayed. @@ -146,22 +149,22 @@ describe('Notifications Menu', () => { it('Marks events in menu as seen', () => { const mockEvents = buildArray(10, (index) => { return eventFactory.build({ - // The event with the highest ID is expected to come first in the array. - id: 5000 - index, action: 'linode_delete', // The response from the API will be ordered by created date, descending. created: DateTime.local().minus({ minutes: index }).toISO(), - percent_complete: null, - seen: false, - rate: null, duration: null, - status: 'scheduled', entity: { id: 1000 + index, label: `my-linode-${index}`, type: 'linode', url: `/v4/linode/instances/${1000 + index}`, }, + // The event with the highest ID is expected to come first in the array. + id: 5000 - index, + percent_complete: null, + rate: null, + seen: false, + status: 'scheduled', username: randomLabel(), }); }); @@ -245,23 +248,23 @@ describe('Notifications Menu', () => { const createTime = DateTime.local().minus({ minutes: 2 }).toISO(); const mockEvents = buildArray(10, (index) => { return eventFactory.build({ - // Events are not guaranteed to be ordered by ID; simulate this by using random IDs. - id: randomNumber(1000, 9999), action: 'linode_delete', - // To simulate multiple events occurring simultaneously, give all // events the same created timestamp. created: createTime, - percent_complete: null, - seen: false, - rate: null, + // To simulate multiple events occurring simultaneously, give all duration: null, - status: 'scheduled', entity: { id: 1000 + index, label: `my-linode-${index}`, type: 'linode', url: `/v4/linode/instances/${1000 + index}`, }, + // Events are not guaranteed to be ordered by ID; simulate this by using random IDs. + id: randomNumber(1000, 9999), + percent_complete: null, + rate: null, + seen: false, + status: 'scheduled', username: randomLabel(), }); }); diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts index 852d60c297e..7cdaa021cc8 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/events.spec.ts @@ -1,17 +1,17 @@ +import { EventActionKeys } from '@linode/api-v4'; import { eventFactory } from '@src/factories/events'; import { mockGetEvents } from 'support/intercepts/events'; -import { EventActionKeys } from '@linode/api-v4'; import type { Event } from '@linode/api-v4'; const events: Event[] = EventActionKeys.map((action) => { return eventFactory.build({ action, + entity: { id: 0, label: 'linode-0' }, message: `${action + ' message'}`, - seen: false, - read: false, percent_complete: null, - entity: { id: 0, label: 'linode-0' }, + read: false, + seen: false, }); }); diff --git a/packages/manager/cypress/e2e/core/notificationsAndEvents/notifications.spec.ts b/packages/manager/cypress/e2e/core/notificationsAndEvents/notifications.spec.ts index bf3ce8cb83f..c7d270fede2 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/notifications.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/notifications.spec.ts @@ -1,20 +1,21 @@ -import { Notification } from '@linode/api-v4'; import { notificationFactory } from '@src/factories/notification'; import { mockGetNotifications } from 'support/intercepts/events'; +import type { Notification } from '@linode/api-v4'; + const notifications: Notification[] = [ notificationFactory.build({ - type: 'migration_scheduled', severity: 'critical', + type: 'migration_scheduled', }), - notificationFactory.build({ type: 'migration_pending', severity: 'major' }), - notificationFactory.build({ type: 'reboot_scheduled', severity: 'minor' }), - notificationFactory.build({ type: 'outage', severity: 'critical' }), - notificationFactory.build({ type: 'ticket_important', severity: 'minor' }), - notificationFactory.build({ type: 'ticket_abuse', severity: 'critical' }), - notificationFactory.build({ type: 'notice', severity: 'major' }), - notificationFactory.build({ type: 'maintenance', severity: 'minor' }), - notificationFactory.build({ type: 'promotion', severity: 'critical' }), + notificationFactory.build({ severity: 'major', type: 'migration_pending' }), + notificationFactory.build({ severity: 'minor', type: 'reboot_scheduled' }), + notificationFactory.build({ severity: 'critical', type: 'outage' }), + notificationFactory.build({ severity: 'minor', type: 'ticket_important' }), + notificationFactory.build({ severity: 'critical', type: 'ticket_abuse' }), + notificationFactory.build({ severity: 'major', type: 'notice' }), + notificationFactory.build({ severity: 'minor', type: 'maintenance' }), + notificationFactory.build({ severity: 'critical', type: 'promotion' }), ]; describe('verify notification types and icons', () => { diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts index 07ca420e56d..dcabea27aba 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts @@ -2,19 +2,20 @@ * @file End-to-end tests for Object Storage Access Key operations. */ -import { createObjectStorageBucketFactoryLegacy } from 'src/factories/objectStorage'; -import { authenticate } from 'support/api/authentication'; import { createBucket } from '@linode/api-v4/lib/object-storage'; +import { authenticate } from 'support/api/authentication'; +import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { - interceptGetAccessKeys, interceptCreateAccessKey, + interceptGetAccessKeys, } from 'support/intercepts/object-storage'; -import { randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; -import { mockGetAccount } from 'support/intercepts/account'; +import { randomLabel } from 'support/util/random'; + import { accountFactory } from 'src/factories'; +import { createObjectStorageBucketFactoryLegacy } from 'src/factories/objectStorage'; authenticate(); describe('object storage access key end-to-end tests', () => { @@ -57,7 +58,8 @@ describe('object storage access key end-to-end tests', () => { .findByTitle('Create Access Key') .should('be.visible') .within(() => { - cy.findByText('Label').click().type(keyLabel); + cy.findByText('Label').click(); + cy.focused().type(keyLabel); ui.buttonGroup .findButtonByTitle('Create Access Key') .should('be.visible') @@ -120,8 +122,8 @@ describe('object storage access key end-to-end tests', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-east-1'; const bucketRequest = createObjectStorageBucketFactoryLegacy.build({ - label: bucketLabel, cluster: bucketCluster, + label: bucketLabel, // Default factory sets `cluster` and `region`, but API does not accept `region` yet. region: undefined, }); @@ -157,7 +159,8 @@ describe('object storage access key end-to-end tests', () => { .findByTitle('Create Access Key') .should('be.visible') .within(() => { - cy.findByText('Label').click().type(keyLabel); + cy.findByText('Label').click(); + cy.focused().type(keyLabel); cy.findByLabelText('Limited Access').click(); cy.findByLabelText('Select read-only for all').click(); diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts index 05eb396a5d3..7b628c96e03 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts @@ -2,17 +2,18 @@ * @file Smoke tests for crucial Object Storage Access Keys operations. */ -import { objectStorageKeyFactory } from 'src/factories/objectStorage'; +import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateAccessKey, mockDeleteAccessKey, mockGetAccessKeys, } from 'support/intercepts/object-storage'; -import { randomLabel, randomNumber, randomString } from 'support/util/random'; import { ui } from 'support/ui'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; + import { accountFactory } from 'src/factories'; -import { mockGetAccount } from 'support/intercepts/account'; +import { objectStorageKeyFactory } from 'src/factories/objectStorage'; describe('object storage access keys smoke tests', () => { /* @@ -23,8 +24,8 @@ describe('object storage access keys smoke tests', () => { */ it('can create access key - smoke', () => { const mockAccessKey = objectStorageKeyFactory.build({ - label: randomLabel(), access_key: randomString(20), + label: randomLabel(), secret_key: randomString(39), }); @@ -51,10 +52,13 @@ describe('object storage access keys smoke tests', () => { .findByTitle('Create Access Key') .should('be.visible') .within(() => { - cy.findByLabelText('Label').click().type(mockAccessKey.label); + cy.findByLabelText('Label').click(); + cy.focused().type(mockAccessKey.label); ui.buttonGroup .findButtonByTitle('Create Access Key') - .scrollIntoView() + .as('qaCreateAccessKey') + .scrollIntoView(); + cy.get('@qaCreateAccessKey') .should('be.visible') .should('be.enabled') .click(); @@ -94,9 +98,9 @@ describe('object storage access keys smoke tests', () => { */ it('can revoke access key - smoke', () => { const accessKey = objectStorageKeyFactory.build({ - label: randomLabel(), - id: randomNumber(1, 99999), access_key: randomString(20), + id: randomNumber(1, 99999), + label: randomLabel(), secret_key: randomString(39), }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts index 7772498eabc..78150cba226 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/enable-object-storage.spec.ts @@ -1,50 +1,50 @@ /** * @file Cypress integration tests for OBJ enrollment and cancellation. */ - -import type { - AccountSettings, - ObjectStorageCluster, - ObjectStorageClusterID, - Region, -} from '@linode/api-v4'; import { + accountFactory, accountSettingsFactory, objectStorageClusterFactory, + objectStorageKeyFactory, profileFactory, regionFactory, - objectStorageKeyFactory, - accountFactory, } from '@src/factories'; import { mockGetAccount, mockGetAccountSettings, } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCancelObjectStorage, mockCreateAccessKey, mockGetBuckets, mockGetClusters, } from 'support/intercepts/object-storage'; +import { mockGetAccessKeys } from 'support/intercepts/object-storage'; import { mockGetProfile } from 'support/intercepts/profile'; +import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { randomLabel } from 'support/util/random'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { mockGetAccessKeys } from 'support/intercepts/object-storage'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; + +import type { + AccountSettings, + ObjectStorageCluster, + ObjectStorageClusterID, + Region, +} from '@linode/api-v4'; // Various messages, notes, and warnings that may be shown when enabling Object Storage // under different circumstances. const objNotes = { - // When enabling OBJ, in both the Access Key flow and Create Bucket flow, when OBJ DC-specific pricing is enabled. - objDCPricing: - 'Object Storage costs a flat rate of $5/month, and includes 250 GB of storage. When you enable Object Storage, 1 TB of outbound data transfer will be added to your global network transfer pool.', + // Information regarding the Object Storage cancellation process. + cancellationExplanation: /To discontinue billing, you.*ll need to cancel Object Storage in your Account Settings./, // Link to further DC-specific pricing information. dcPricingLearnMoreNote: 'Learn more about pricing and specifications.', - // Information regarding the Object Storage cancellation process. - cancellationExplanation: /To discontinue billing, you.*ll need to cancel Object Storage in your Account Settings./, + // When enabling OBJ, in both the Access Key flow and Create Bucket flow, when OBJ DC-specific pricing is enabled. + objDCPricing: + 'Object Storage costs a flat rate of $5/month, and includes 250 GB of storage. When you enable Object Storage, 1 TB of outbound data transfer will be added to your global network transfer pool.', }; describe('Object Storage enrollment', () => { @@ -75,18 +75,18 @@ describe('Object Storage enrollment', () => { const mockRegions: Region[] = [ regionFactory.build({ capabilities: ['Object Storage'], - label: 'Newark, NJ', id: 'us-east', + label: 'Newark, NJ', }), regionFactory.build({ capabilities: ['Object Storage'], - label: 'Sao Paulo, BR', id: 'br-gru', + label: 'Sao Paulo, BR', }), regionFactory.build({ capabilities: ['Object Storage'], - label: 'Jakarta, ID', id: 'id-cgk', + label: 'Jakarta, ID', }), ]; @@ -145,7 +145,7 @@ describe('Object Storage enrollment', () => { .findByTitle('Create Bucket') .should('be.visible') .within(() => { - cy.findByLabelText('Label (required)') + cy.findByLabelText('Bucket Name (required)') .should('be.visible') .type(randomLabel()); diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts index 1fa96cb91c1..71d45be1a66 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.e2e.spec.ts @@ -3,26 +3,27 @@ */ import { createBucket } from '@linode/api-v4/lib/object-storage'; -import { - accountFactory, - createObjectStorageBucketFactoryLegacy, -} from 'src/factories'; import { authenticate } from 'support/api/authentication'; import { interceptGetNetworkUtilization, mockGetAccount, } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { interceptCreateBucket, interceptDeleteBucket, - interceptGetBuckets, interceptGetBucketAccess, + interceptGetBuckets, interceptUpdateBucketAccess, } from 'support/intercepts/object-storage'; import { ui } from 'support/ui'; -import { randomLabel } from 'support/util/random'; import { cleanUp } from 'support/util/cleanup'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { randomLabel } from 'support/util/random'; + +import { + accountFactory, + createObjectStorageBucketFactoryLegacy, +} from 'src/factories'; /** * Create a bucket with the given label and cluster. @@ -43,9 +44,9 @@ const setUpBucket = ( ) => { return createBucket( createObjectStorageBucketFactoryLegacy.build({ - label, cluster, cors_enabled, + label, // API accepts either `cluster` or `region`, but not both. Our factory // populates both fields, so we have to manually set `region` to `undefined` @@ -95,7 +96,7 @@ describe('object storage end-to-end tests', () => { // Wait for loader to disappear, indicating that all buckets have been loaded. // Mitigates test failures stemming from M3-7833. - cy.findByLabelText('Buckets').within(() => { + cy.findByTestId('Buckets').within(() => { cy.findByLabelText('Content is loading').should('not.exist'); }); @@ -107,8 +108,10 @@ describe('object storage end-to-end tests', () => { .findByTitle('Create Bucket') .should('be.visible') .within(() => { - cy.findByText('Label').click().type(bucketLabel); - ui.regionSelect.find().click().type(`${bucketRegion}{enter}`); + cy.findByLabelText('Bucket Name (required)').click(); + cy.focused().type(bucketLabel); + ui.regionSelect.find().click(); + cy.focused().type(`${bucketRegion}{enter}`); ui.buttonGroup .findButtonByTitle('Create Bucket') @@ -133,7 +136,8 @@ describe('object storage end-to-end tests', () => { .findByTitle(`Delete Bucket ${bucketLabel}`) .should('be.visible') .within(() => { - cy.findByLabelText('Bucket Name').click().type(bucketLabel); + cy.findByLabelText('Bucket Name').click(); + cy.focused().type(bucketLabel); ui.buttonGroup .findButtonByTitle('Delete') .should('be.visible') @@ -178,8 +182,8 @@ describe('object storage end-to-end tests', () => { .should('be.visible') .should('not.have.value', 'Loading access...') .should('have.value', 'Private') - .click() - .type('Public Read'); + .click(); + cy.focused().type('Public Read'); ui.autocompletePopper .findByTitle('Public Read') diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts index 479bd129fbb..8f9e618c2c9 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts @@ -3,22 +3,23 @@ */ import 'cypress-file-upload'; -import { objectStorageBucketFactory } from 'src/factories/objectStorage'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateBucket, mockDeleteBucket, mockDeleteBucketObject, mockDeleteBucketObjectS3, - mockGetBuckets, mockGetBucketObjects, + mockGetBuckets, mockUploadBucketObject, mockUploadBucketObjectS3, } from 'support/intercepts/object-storage'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; +import { randomLabel } from 'support/util/random'; + import { accountFactory } from 'src/factories'; -import { mockGetAccount } from 'support/intercepts/account'; +import { objectStorageBucketFactory } from 'src/factories/objectStorage'; describe('object storage smoke tests', () => { /* @@ -33,16 +34,16 @@ describe('object storage smoke tests', () => { const bucketHostname = `${bucketLabel}.${bucketCluster}.linodeobjects.com`; const mockBucket = objectStorageBucketFactory.build({ - label: bucketLabel, cluster: bucketCluster, hostname: bucketHostname, + label: bucketLabel, }); mockGetAccount(accountFactory.build({ capabilities: ['Object Storage'] })); mockAppendFeatureFlags({ + gecko2: false, objMultiCluster: false, objectStorageGen2: { enabled: false }, - gecko2: false, }).as('getFeatureFlags'); mockGetBuckets([]).as('getBuckets'); @@ -62,8 +63,10 @@ describe('object storage smoke tests', () => { .findByTitle('Create Bucket') .should('be.visible') .within(() => { - cy.findByText('Label').click().type(bucketLabel); - ui.regionSelect.find().click().type(`${bucketRegion}{enter}`); + cy.findByLabelText('Bucket Name (required)').click(); + cy.focused().type(bucketLabel); + ui.regionSelect.find().click(); + cy.focused().type(`${bucketRegion}{enter}`); ui.buttonGroup .findButtonByTitle('Create Bucket') .should('be.visible') @@ -170,9 +173,9 @@ describe('object storage smoke tests', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-southeast-1'; const bucketMock = objectStorageBucketFactory.build({ - label: bucketLabel, cluster: bucketCluster, hostname: `${bucketLabel}.${bucketCluster}.linodeobjects.com`, + label: bucketLabel, objects: 0, }); @@ -199,7 +202,8 @@ describe('object storage smoke tests', () => { .findByTitle(`Delete Bucket ${bucketLabel}`) .should('be.visible') .within(() => { - cy.findByLabelText('Bucket Name').click().type(bucketLabel); + cy.findByLabelText('Bucket Name').click(); + cy.focused().type(bucketLabel); ui.buttonGroup .findButtonByTitle('Delete') .should('be.enabled') diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-access-keys-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-access-keys-gen2.spec.ts index d7da620f401..1141986b61b 100644 --- a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-access-keys-gen2.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-access-keys-gen2.spec.ts @@ -1,10 +1,11 @@ import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { mockGetProfile } from 'support/intercepts/profile'; import { mockGetAccessKeys } from 'support/intercepts/object-storage'; +import { mockGetProfile } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; + import { accountFactory, objectStorageKeyFactory } from 'src/factories'; import { profileFactory } from 'src/factories/profile'; -import { ui } from 'support/ui'; describe('Object Storage gen2 access keys tests', () => { /** @@ -28,20 +29,20 @@ describe('Object Storage gen2 access keys tests', () => { const mockAccessKey1 = objectStorageKeyFactory.build({ regions: [ - { id: 'us-east', s3_endpoint: 'us-east.com', endpoint_type: 'E3' }, + { endpoint_type: 'E3', id: 'us-east', s3_endpoint: 'us-east.com' }, ], }); const mockAccessKey2 = objectStorageKeyFactory.build({ regions: [ { + endpoint_type: 'E3', id: 'us-southeast', s3_endpoint: 'us-southeast.com', - endpoint_type: 'E3', }, - { id: 'in-maa', s3_endpoint: 'in-maa.com', endpoint_type: 'E2' }, - { id: 'us-mia', s3_endpoint: 'us-mia.com', endpoint_type: 'E1' }, - { id: 'it-mil', s3_endpoint: 'it-mil.com', endpoint_type: 'E0' }, + { endpoint_type: 'E2', id: 'in-maa', s3_endpoint: 'in-maa.com' }, + { endpoint_type: 'E1', id: 'us-mia', s3_endpoint: 'us-mia.com' }, + { endpoint_type: 'E0', id: 'it-mil', s3_endpoint: 'it-mil.com' }, ], }); @@ -126,7 +127,7 @@ describe('Object Storage Gen2 create access key modal has disabled fields for re .should('be.visible') .within(() => { cy.findByText( - /You don't have bucket_access to create an Access Key./ + /You don't have permissions to create an Access Key./ ).should('be.visible'); // label cy.findByLabelText(/Label.*/) diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts index 69aca1c76c6..f7cf9eccc6b 100644 --- a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-create-gen2.spec.ts @@ -1,18 +1,20 @@ import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { - mockGetObjectStorageEndpoints, - mockGetBuckets, - mockDeleteBucket, mockCreateBucket, - mockGetBucketAccess, mockCreateBucketError, + mockDeleteBucket, + mockGetBucketAccess, + mockGetBuckets, + mockGetObjectStorageEndpoints, } from 'support/intercepts/object-storage'; import { mockGetProfile } from 'support/intercepts/profile'; import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { checkRateLimitsTable } from 'support/util/object-storage-gen2'; import { randomLabel } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + import { accountFactory, objectStorageBucketFactoryGen2, @@ -20,7 +22,7 @@ import { regionFactory, } from 'src/factories'; import { profileFactory } from 'src/factories/profile'; -import { chooseRegion } from 'support/util/regions'; + import type { ACLType, ObjectStorageEndpoint } from '@linode/api-v4'; describe('Object Storage Gen2 create bucket tests', () => { @@ -97,8 +99,6 @@ describe('Object Storage Gen2 create bucket tests', () => { endpointType === 'Standard (E3)' || endpointType === 'Standard (E2)' ) { - cy.contains(bucketRateLimitsNotice).should('be.visible'); - cy.get('[data-testid="bucket-rate-limit-table"]').should('be.visible'); cy.contains(CORSNotice).should('be.visible'); ui.toggle.find().should('not.exist'); } else { @@ -152,9 +152,9 @@ describe('Object Storage Gen2 create bucket tests', () => { mockGetBuckets([]).as('getBuckets'); mockDeleteBucket(bucketLabel, mockRegion.id).as('deleteBucket'); mockCreateBucket({ - label: bucketLabel, - endpoint_type: 'E0', cors_enabled: true, + endpoint_type: 'E0', + label: bucketLabel, region: mockRegion.id, }).as('createBucket'); @@ -176,9 +176,9 @@ describe('Object Storage Gen2 create bucket tests', () => { ]); const mockBucket = objectStorageBucketFactoryGen2.build({ + endpoint_type: 'E0', label: bucketLabel, region: mockRegion.id, - endpoint_type: 'E0', s3_endpoint: undefined, }); @@ -186,8 +186,10 @@ describe('Object Storage Gen2 create bucket tests', () => { .findByTitle('Create Bucket') .should('be.visible') .within(() => { - cy.findByText('Label').click().type(bucketLabel); - ui.regionSelect.find().click().type(`${mockRegion.label}{enter}`); + cy.findByLabelText('Bucket Name (required)').click(); + cy.focused().type(bucketLabel); + ui.regionSelect.find().click(); + cy.focused().type(`${mockRegion.label}{enter}`); cy.findByLabelText('Object Storage Endpoint Type') .should('be.visible') .click(); @@ -264,7 +266,8 @@ describe('Object Storage Gen2 create bucket tests', () => { .findByTitle(`Delete Bucket ${bucketLabel}`) .should('be.visible') .within(() => { - cy.findByLabelText('Bucket Name').click().type(bucketLabel); + cy.findByLabelText('Bucket Name').click(); + cy.focused().type(bucketLabel); ui.buttonGroup .findButtonByTitle('Delete') .should('be.visible') @@ -290,9 +293,9 @@ describe('Object Storage Gen2 create bucket tests', () => { mockGetBuckets([]).as('getBuckets'); mockDeleteBucket(bucketLabel, mockRegion.id).as('deleteBucket'); mockCreateBucket({ - label: bucketLabel, - endpoint_type: 'E1', cors_enabled: true, + endpoint_type: 'E1', + label: bucketLabel, region: mockRegion.id, }).as('createBucket'); @@ -314,9 +317,9 @@ describe('Object Storage Gen2 create bucket tests', () => { ]); const mockBucket = objectStorageBucketFactoryGen2.build({ + endpoint_type: 'E1', label: bucketLabel, region: mockRegion.id, - endpoint_type: 'E1', s3_endpoint: 'us-sea-1.linodeobjects.com', }); @@ -324,8 +327,10 @@ describe('Object Storage Gen2 create bucket tests', () => { .findByTitle('Create Bucket') .should('be.visible') .within(() => { - cy.findByText('Label').click().type(bucketLabel); - ui.regionSelect.find().click().type(`${mockRegion.label}{enter}`); + cy.findByLabelText('Bucket Name (required)').click(); + cy.focused().type(bucketLabel); + ui.regionSelect.find().click(); + cy.focused().type(`${mockRegion.label}{enter}`); cy.findByLabelText('Object Storage Endpoint Type') .should('be.visible') .click(); @@ -387,7 +392,8 @@ describe('Object Storage Gen2 create bucket tests', () => { .findByTitle(`Delete Bucket ${bucketLabel}`) .should('be.visible') .within(() => { - cy.findByLabelText('Bucket Name').click().type(bucketLabel); + cy.findByLabelText('Bucket Name').click(); + cy.focused().type(bucketLabel); ui.buttonGroup .findButtonByTitle('Delete') .should('be.visible') @@ -413,9 +419,9 @@ describe('Object Storage Gen2 create bucket tests', () => { mockGetBuckets([]).as('getBuckets'); mockDeleteBucket(bucketLabel, mockRegion.id).as('deleteBucket'); mockCreateBucket({ - label: bucketLabel, - endpoint_type: 'E2', cors_enabled: true, + endpoint_type: 'E2', + label: bucketLabel, region: mockRegion.id, }).as('createBucket'); @@ -437,9 +443,9 @@ describe('Object Storage Gen2 create bucket tests', () => { ]); const mockBucket = objectStorageBucketFactoryGen2.build({ + endpoint_type: 'E2', label: bucketLabel, region: mockRegion.id, - endpoint_type: 'E2', s3_endpoint: undefined, }); @@ -447,8 +453,10 @@ describe('Object Storage Gen2 create bucket tests', () => { .findByTitle('Create Bucket') .should('be.visible') .within(() => { - cy.findByText('Label').click().type(bucketLabel); - ui.regionSelect.find().click().type(`${mockRegion.label}{enter}`); + cy.findByLabelText('Bucket Name (required)').click(); + cy.focused().type(bucketLabel); + ui.regionSelect.find().click(); + cy.focused().type(`${mockRegion.label}{enter}`); cy.findByLabelText('Object Storage Endpoint Type') .should('be.visible') .click(); @@ -508,7 +516,8 @@ describe('Object Storage Gen2 create bucket tests', () => { .findByTitle(`Delete Bucket ${bucketLabel}`) .should('be.visible') .within(() => { - cy.findByLabelText('Bucket Name').click().type(bucketLabel); + cy.findByLabelText('Bucket Name').click(); + cy.focused().type(bucketLabel); ui.buttonGroup .findButtonByTitle('Delete') .should('be.visible') @@ -534,9 +543,9 @@ describe('Object Storage Gen2 create bucket tests', () => { mockGetBuckets([]).as('getBuckets'); mockDeleteBucket(bucketLabel, mockRegion.id).as('deleteBucket'); mockCreateBucket({ - label: bucketLabel, - endpoint_type: 'E3', cors_enabled: false, + endpoint_type: 'E3', + label: bucketLabel, region: mockRegion.id, }).as('createBucket'); @@ -558,9 +567,9 @@ describe('Object Storage Gen2 create bucket tests', () => { ]); const mockBucket = objectStorageBucketFactoryGen2.build({ + endpoint_type: 'E3', label: bucketLabel, region: mockRegion.id, - endpoint_type: 'E3', s3_endpoint: undefined, }); @@ -568,8 +577,10 @@ describe('Object Storage Gen2 create bucket tests', () => { .findByTitle('Create Bucket') .should('be.visible') .within(() => { - cy.findByText('Label').click().type(bucketLabel); - ui.regionSelect.find().click().type(`${mockRegion.label}{enter}`); + cy.findByLabelText('Bucket Name (required)').click(); + cy.focused().type(bucketLabel); + ui.regionSelect.find().click(); + cy.focused().type(`${mockRegion.label}{enter}`); cy.findByLabelText('Object Storage Endpoint Type') .should('be.visible') .click(); @@ -629,7 +640,8 @@ describe('Object Storage Gen2 create bucket tests', () => { .findByTitle(`Delete Bucket ${bucketLabel}`) .should('be.visible') .within(() => { - cy.findByLabelText('Bucket Name').click().type(bucketLabel); + cy.findByLabelText('Bucket Name').click(); + cy.focused().type(bucketLabel); ui.buttonGroup .findButtonByTitle('Delete') .should('be.visible') @@ -702,9 +714,10 @@ describe('Object Storage Gen2 create bucket tests', () => { .should('be.enabled') .click(); - cy.contains('Label is required.').should('be.visible'); - cy.findByText('Label').click().type(bucketLabel); - cy.contains('Label is required.').should('not.exist'); + cy.contains('Bucket name is required.').should('be.visible'); + cy.findByLabelText('Bucket Name (required)').click(); + cy.focused().type(bucketLabel); + cy.contains('Bucket name is required.').should('not.exist'); // confirms (mock) API error appears ui.buttonGroup @@ -759,14 +772,14 @@ describe('Object Storage Gen2 create bucket modal has disabled fields for restri cy.findByText(/You don't have permissions to create a Bucket./).should( 'be.visible' ); - cy.findByLabelText(/Label.*/) + cy.findByLabelText('Bucket Name (required)') .should('be.visible') .should('be.disabled'); ui.regionSelect.find().should('be.visible').should('be.disabled'); - // submit button should be enabled + // submit button should be disabled cy.findByTestId('create-bucket-button') .should('be.visible') - .should('be.enabled'); + .should('be.disabled'); }); }); }); diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-details-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-details-gen2.spec.ts index a8bf39db270..6ac187e0c66 100644 --- a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-details-gen2.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-details-gen2.spec.ts @@ -1,17 +1,19 @@ import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { + mockGetBucketAccess, mockGetBucketsForRegion, mockGetObjectStorageEndpoints, - mockGetBucketAccess, } from 'support/intercepts/object-storage'; + import { accountFactory, objectStorageBucketFactoryGen2, objectStorageEndpointsFactory, regionFactory, } from 'src/factories'; -import { ACLType, ObjectStorageEndpointTypes } from '@linode/api-v4'; + +import type { ACLType, ObjectStorageEndpointTypes } from '@linode/api-v4'; describe('Object Storage Gen 2 bucket details tabs', () => { beforeEach(() => { diff --git a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts index b5f94c08487..dd1feeda2b4 100644 --- a/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageGen2/bucket-object-gen2.spec.ts @@ -1,17 +1,6 @@ import 'cypress-file-upload'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { - accountFactory, - objectStorageBucketFactoryGen2, - objectStorageEndpointsFactory, - regionFactory, -} from 'src/factories'; -import { chooseRegion } from 'support/util/regions'; -import { mockGetRegions } from 'support/intercepts/regions'; -import { ObjectStorageEndpoint } from '@linode/api-v4'; -import { randomItem, randomLabel } from 'support/util/random'; -import { extendRegion } from 'support/util/regions'; import { mockCreateBucket, mockGetBucket, @@ -23,7 +12,20 @@ import { mockUploadBucketObject, mockUploadBucketObjectS3, } from 'support/intercepts/object-storage'; +import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; +import { randomItem, randomLabel } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; +import { extendRegion } from 'support/util/regions'; + +import { + accountFactory, + objectStorageBucketFactoryGen2, + objectStorageEndpointsFactory, + regionFactory, +} from 'src/factories'; + +import type { ObjectStorageEndpoint } from '@linode/api-v4'; describe('Object Storage Gen2 bucket object tests', () => { beforeEach(() => { @@ -133,17 +135,17 @@ describe('Object Storage Gen2 bucket object tests', () => { const bucketLabel = randomLabel(); const bucketCluster = mockRegion.id; const mockBucket = objectStorageBucketFactoryGen2.build({ + endpoint_type: 'E0', label: bucketLabel, region: mockRegion.id, - endpoint_type: 'E0', s3_endpoint: undefined, }); - //mockGetBuckets([]).as('getBuckets'); + // mockGetBuckets([]).as('getBuckets'); mockCreateBucket({ - label: bucketLabel, - endpoint_type: 'E0', cors_enabled: true, + endpoint_type: 'E0', + label: bucketLabel, region: mockRegion.id, }).as('createBucket'); mockGetBucketsForRegion(mockRegion.id, [mockBucket]).as('getBuckets'); @@ -193,17 +195,17 @@ describe('Object Storage Gen2 bucket object tests', () => { const bucketLabel = randomLabel(); const bucketCluster = mockRegion.id; const mockBucket = objectStorageBucketFactoryGen2.build({ + endpoint_type: 'E1', label: bucketLabel, region: mockRegion.id, - endpoint_type: 'E1', s3_endpoint: 'us-sea-1.linodeobjects.com', }); - //mockGetBuckets([]).as('getBuckets'); + // mockGetBuckets([]).as('getBuckets'); mockCreateBucket({ - label: bucketLabel, - endpoint_type: 'E1', cors_enabled: true, + endpoint_type: 'E1', + label: bucketLabel, region: mockRegion.id, }).as('createBucket'); mockGetBucketsForRegion(mockRegion.id, [mockBucket]).as('getBuckets'); @@ -253,16 +255,16 @@ describe('Object Storage Gen2 bucket object tests', () => { const bucketLabel = randomLabel(); const bucketCluster = mockRegion.id; const mockBucket = objectStorageBucketFactoryGen2.build({ + endpoint_type: 'E2', label: bucketLabel, region: mockRegion.id, - endpoint_type: 'E2', s3_endpoint: undefined, }); mockCreateBucket({ - label: bucketLabel, - endpoint_type: 'E2', cors_enabled: true, + endpoint_type: 'E2', + label: bucketLabel, region: mockRegion.id, }).as('createBucket'); mockGetBucketsForRegion(mockRegion.id, [mockBucket]).as('getBuckets'); @@ -313,16 +315,16 @@ describe('Object Storage Gen2 bucket object tests', () => { const bucketLabel = randomLabel(); const bucketCluster = mockRegion.id; const mockBucket = objectStorageBucketFactoryGen2.build({ + endpoint_type: 'E3', label: bucketLabel, region: mockRegion.id, - endpoint_type: 'E3', s3_endpoint: undefined, }); mockCreateBucket({ - label: bucketLabel, - endpoint_type: 'E3', cors_enabled: true, + endpoint_type: 'E3', + label: bucketLabel, region: mockRegion.id, }).as('createBucket'); mockGetBucketsForRegion(mockRegion.id, [mockBucket]).as('getBuckets'); diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts index 56e1e24d44f..df03e62632c 100644 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts @@ -1,27 +1,28 @@ -import { buildArray } from 'support/util/arrays'; -import { extendRegion } from 'support/util/regions'; -import { - accountFactory, - regionFactory, - objectStorageKeyFactory, - objectStorageBucketFactory, -} from 'src/factories'; -import { - randomString, - randomNumber, - randomLabel, - randomDomainName, -} from 'support/util/random'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetAccount } from 'support/intercepts/account'; -import { mockGetRegions } from 'support/intercepts/regions'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { - mockGetAccessKeys, mockCreateAccessKey, + mockGetAccessKeys, mockGetBucketsForRegion, mockUpdateAccessKey, } from 'support/intercepts/object-storage'; +import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; +import { buildArray } from 'support/util/arrays'; +import { + randomDomainName, + randomLabel, + randomNumber, + randomString, +} from 'support/util/random'; +import { extendRegion } from 'support/util/regions'; + +import { + accountFactory, + objectStorageBucketFactory, + objectStorageKeyFactory, + regionFactory, +} from 'src/factories'; import type { ObjectStorageKeyBucketAccess } from '@linode/api-v4'; @@ -29,9 +30,9 @@ describe('Object Storage Multicluster access keys', () => { const mockRegionsObj = buildArray(3, () => { return extendRegion( regionFactory.build({ + capabilities: ['Object Storage'], id: `us-${randomString(5)}`, label: `mock-obj-region-${randomString(5)}`, - capabilities: ['Object Storage'], }) ); }); @@ -57,14 +58,14 @@ describe('Object Storage Multicluster access keys', () => { */ it('can create unlimited access keys with OBJ Multicluster', () => { const mockAccessKey = objectStorageKeyFactory.build({ + access_key: randomString(20), id: randomNumber(10000, 99999), label: randomLabel(), - access_key: randomString(20), - secret_key: randomString(39), regions: mockRegionsObj.map((mockObjRegion) => ({ id: mockObjRegion.id, s3_endpoint: randomDomainName(), })), + secret_key: randomString(39), }); mockGetAccessKeys([]); @@ -87,10 +88,8 @@ describe('Object Storage Multicluster access keys', () => { .findByTitle('Create Access Key') .should('be.visible') .within(() => { - cy.contains('Label (required)') - .should('be.visible') - .click() - .type(mockAccessKey.label); + cy.contains('Label (required)').should('be.visible').click(); + cy.focused().type(mockAccessKey.label); cy.contains('Regions (required)').should('be.visible').click(); @@ -104,10 +103,8 @@ describe('Object Storage Multicluster access keys', () => { }); // Close the regions drop-down. - cy.contains('Regions (required)') - .should('be.visible') - .click() - .type('{esc}'); + cy.contains('Regions (required)').should('be.visible').click(); + cy.focused().type('{esc}'); // TODO Confirm expected regions are shown. ui.buttonGroup @@ -154,29 +151,19 @@ describe('Object Storage Multicluster access keys', () => { it('can create limited access keys with OBJ Multicluster', () => { const mockRegion = extendRegion( regionFactory.build({ + capabilities: ['Object Storage'], id: `us-${randomString(5)}`, label: `mock-obj-region-${randomString(5)}`, - capabilities: ['Object Storage'], }) ); const mockBuckets = objectStorageBucketFactory.buildList(2, { - region: mockRegion.id, cluster: undefined, + region: mockRegion.id, }); const mockAccessKey = objectStorageKeyFactory.build({ - id: randomNumber(10000, 99999), - label: randomLabel(), access_key: randomString(20), - secret_key: randomString(39), - regions: [ - { - id: mockRegion.id, - s3_endpoint: randomDomainName(), - }, - ], - limited: true, bucket_access: mockBuckets.map( (bucket): ObjectStorageKeyBucketAccess => ({ bucket_name: bucket.label, @@ -185,6 +172,16 @@ describe('Object Storage Multicluster access keys', () => { region: mockRegion.id, }) ), + id: randomNumber(10000, 99999), + label: randomLabel(), + limited: true, + regions: [ + { + id: mockRegion.id, + s3_endpoint: randomDomainName(), + }, + ], + secret_key: randomString(39), }); mockGetAccessKeys([]); @@ -205,25 +202,19 @@ describe('Object Storage Multicluster access keys', () => { .findByTitle('Create Access Key') .should('be.visible') .within(() => { - cy.contains('Label (required)') - .should('be.visible') - .click() - .type(mockAccessKey.label); + cy.contains('Label (required)').should('be.visible').click(); + cy.focused().type(mockAccessKey.label); - cy.contains('Regions (required)') - .should('be.visible') - .click() - .type(`${mockRegion.label}{enter}`); + cy.contains('Regions (required)').should('be.visible').click(); + cy.focused().type(`${mockRegion.label}{enter}`); ui.autocompletePopper .findByTitle(`${mockRegion.label} (${mockRegion.id})`) .should('be.visible'); // Dismiss region drop-down. - cy.contains('Regions (required)') - .should('be.visible') - .click() - .type('{esc}'); + cy.contains('Regions (required)').should('be.visible').click(); + cy.focused().type('{esc}'); // Enable "Limited Access" toggle for access key and confirm Create button is disabled. cy.findByText('Limited Access').should('be.visible').click(); @@ -296,33 +287,33 @@ describe('Object Storage Multicluster access keys', () => { it('can update access keys with OBJ Multicluster', () => { const mockInitialRegion = extendRegion( regionFactory.build({ + capabilities: ['Object Storage'], id: `us-${randomString(5)}`, label: `mock-obj-region-${randomString(5)}`, - capabilities: ['Object Storage'], }) ); const mockUpdatedRegion = extendRegion( regionFactory.build({ + capabilities: ['Object Storage'], id: `us-${randomString(5)}`, label: `mock-obj-region-${randomString(5)}`, - capabilities: ['Object Storage'], }) ); const mockRegions = [mockInitialRegion, mockUpdatedRegion]; const mockAccessKey = objectStorageKeyFactory.build({ + access_key: randomString(20), id: randomNumber(10000, 99999), label: randomLabel(), - access_key: randomString(20), - secret_key: randomString(39), regions: [ { id: mockInitialRegion.id, s3_endpoint: randomDomainName(), }, ], + secret_key: randomString(39), }); const mockUpdatedAccessKeyEndpoint = randomDomainName(); @@ -364,16 +355,12 @@ describe('Object Storage Multicluster access keys', () => { .findByTitle('Edit Access Key') .should('be.visible') .within(() => { - cy.contains('Label (required)') - .should('be.visible') - .click() - .type('{selectall}{backspace}') - .type(mockUpdatedAccessKey.label); + cy.contains('Label (required)').should('be.visible').click(); + cy.focused().type('{selectall}{backspace}'); + cy.focused().type(mockUpdatedAccessKey.label); - cy.contains('Regions (required)') - .should('be.visible') - .click() - .type(`${mockUpdatedRegion.label}{enter}{esc}`); + cy.contains('Regions (required)').should('be.visible').click(); + cy.focused().type(`${mockUpdatedRegion.label}{enter}{esc}`); cy.contains(mockUpdatedRegion.label).should('be.visible').and('exist'); diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts index cccbd8542cd..a6ba75cd979 100644 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-create-multicluster.spec.ts @@ -1,19 +1,20 @@ -import { extendRegion } from 'support/util/regions'; -import { - accountFactory, - regionFactory, - objectStorageBucketFactory, -} from 'src/factories'; -import { randomLabel, randomString } from 'support/util/random'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockGetAccount } from 'support/intercepts/account'; -import { mockGetRegions } from 'support/intercepts/regions'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { mockCreateBucket, mockCreateBucketError, mockGetBuckets, } from 'support/intercepts/object-storage'; +import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; +import { randomLabel, randomString } from 'support/util/random'; +import { extendRegion } from 'support/util/regions'; + +import { + accountFactory, + objectStorageBucketFactory, + regionFactory, +} from 'src/factories'; describe('Object Storage Multicluster Bucket create', () => { /* @@ -29,9 +30,9 @@ describe('Object Storage Multicluster Bucket create', () => { const mockRegionWithObj = extendRegion( regionFactory.build({ - label: randomLabel(), - id: `${randomString(2)}-${randomString(3)}`, capabilities: ['Object Storage'], + id: `${randomString(2)}-${randomString(3)}`, + label: randomLabel(), }) ); @@ -44,10 +45,10 @@ describe('Object Storage Multicluster Bucket create', () => { const mockRegions = [mockRegionWithObj, ...mockRegionsWithoutObj]; const mockBucket = objectStorageBucketFactory.build({ - label: randomLabel(), - region: mockRegionWithObj.id, cluster: undefined, + label: randomLabel(), objects: 0, + region: mockRegionWithObj.id, }); mockGetAccount( @@ -76,9 +77,11 @@ describe('Object Storage Multicluster Bucket create', () => { .should('be.visible') .within(() => { // Enter label. - cy.contains('Label').click().type(mockBucket.label); + cy.findByLabelText('Bucket Name (required)').click(); + cy.focused().type(mockBucket.label); cy.log(`${mockRegionWithObj.label}`); - cy.contains('Region').click().type(mockRegionWithObj.label); + cy.contains('Region').click(); + cy.focused().type(mockRegionWithObj.label); ui.autocompletePopper .find() diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-delete-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-delete-multicluster.spec.ts index d810cab82ab..b6bb0f1dce4 100644 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-delete-multicluster.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-delete-multicluster.spec.ts @@ -1,12 +1,13 @@ -import { randomLabel } from 'support/util/random'; -import { accountFactory, objectStorageBucketFactory } from 'src/factories'; import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { - mockGetBuckets, mockDeleteBucket, + mockGetBuckets, } from 'support/intercepts/object-storage'; import { ui } from 'support/ui'; +import { randomLabel } from 'support/util/random'; + +import { accountFactory, objectStorageBucketFactory } from 'src/factories'; describe('Object Storage Multicluster Bucket delete', () => { /* @@ -18,9 +19,9 @@ describe('Object Storage Multicluster Bucket delete', () => { const bucketLabel = randomLabel(); const bucketCluster = 'us-southeast-1'; const bucketMock = objectStorageBucketFactory.build({ - label: bucketLabel, cluster: bucketCluster, hostname: `${bucketLabel}.${bucketCluster}.linodeobjects.com`, + label: bucketLabel, objects: 0, }); @@ -51,7 +52,8 @@ describe('Object Storage Multicluster Bucket delete', () => { .findByTitle(`Delete Bucket ${bucketLabel}`) .should('be.visible') .within(() => { - cy.findByLabelText('Bucket Name').click().type(bucketLabel); + cy.findByLabelText('Bucket Name').click(); + cy.focused().type(bucketLabel); ui.buttonGroup .findButtonByTitle('Delete') .should('be.enabled') diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts index a5cc6e7158e..d6769154a3a 100644 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/bucket-details-multicluster.spec.ts @@ -1,14 +1,15 @@ import { mockGetAccount } from 'support/intercepts/account'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { mockGetBucket } from 'support/intercepts/object-storage'; +import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; +import { randomLabel } from 'support/util/random'; + import { accountFactory, objectStorageBucketFactory, regionFactory, } from 'src/factories'; -import { randomLabel } from 'support/util/random'; -import { mockGetBucket } from 'support/intercepts/object-storage'; -import { mockGetRegions } from 'support/intercepts/regions'; describe('Object Storage Multicluster Bucket Details Tabs', () => { beforeEach(() => { diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts index 1138f4f99dc..c89f507d138 100644 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts @@ -1,12 +1,13 @@ +import { createBucket } from '@linode/api-v4'; import 'cypress-file-upload'; import { authenticate } from 'support/api/authentication'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { interceptUploadBucketObjectS3 } from 'support/intercepts/object-storage'; +import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { randomLabel } from 'support/util/random'; -import { ui } from 'support/ui'; + import { createObjectStorageBucketFactoryGen1 } from 'src/factories'; -import { interceptUploadBucketObjectS3 } from 'support/intercepts/object-storage'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; -import { createBucket } from '@linode/api-v4'; // Message shown on-screen when user navigates to an empty bucket. const emptyBucketMessage = 'This bucket is empty.'; @@ -47,14 +48,14 @@ const setUpBucketMulticluster = ( ) => { return createBucket( createObjectStorageBucketFactoryGen1.build({ - label, - region: regionId, + // to avoid 400 responses from the API. + cluster: undefined, cors_enabled, + label, // API accepts either `cluster` or `region`, but not both. Our factory // populates both fields, so we have to manually set `cluster` to `undefined` - // to avoid 400 responses from the API. - cluster: undefined, + region: regionId, }) ); }; @@ -73,8 +74,8 @@ const assertStatusForUrlAtAlias = ( // An alias can resolve to anything. We're assuming the user passed a valid // alias which resolves to a string. cy.request({ - url: url as string, failOnStatusCode: false, + url: url as string, }).then((response) => { expect(response.status).to.eq(expectedStatus); }); @@ -136,8 +137,8 @@ describe('Object Storage Multicluster objects', () => { const bucketFolderName = randomLabel(); const bucketFiles = [ - { path: 'object-storage-files/1.txt', name: '1.txt' }, - { path: 'object-storage-files/2.jpg', name: '2.jpg' }, + { name: '1.txt', path: 'object-storage-files/1.txt' }, + { name: '2.jpg', path: 'object-storage-files/2.jpg' }, ]; cy.defer( @@ -185,10 +186,8 @@ describe('Object Storage Multicluster objects', () => { .findByTitle('Create Folder') .should('be.visible') .within(() => { - cy.findByLabelText('Folder Name') - .should('be.visible') - .click() - .type(bucketFolderName); + cy.findByLabelText('Folder Name').should('be.visible').click(); + cy.focused().type(bucketFolderName); ui.buttonGroup .findButtonByTitle('Create') @@ -228,7 +227,8 @@ describe('Object Storage Multicluster objects', () => { .findByTitle(`Delete Bucket ${bucketLabel}`) .should('be.visible') .within(() => { - cy.findByText('Bucket Name').click().type(bucketLabel); + cy.findByText('Bucket Name').click(); + cy.focused().type(bucketLabel); ui.buttonGroup .findButtonByTitle('Delete') @@ -279,8 +279,8 @@ describe('Object Storage Multicluster objects', () => { .should('be.visible') .should('not.have.value', 'Loading access...') .should('have.value', 'Private') - .click() - .type('Public Read'); + .click(); + cy.focused().type('Public Read'); ui.autocompletePopper .findByTitle('Public Read') diff --git a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts index e7a748d2979..428858ef6ea 100644 --- a/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts +++ b/packages/manager/cypress/e2e/core/oneClickApps/one-click-apps.spec.ts @@ -1,20 +1,21 @@ -import { ui } from 'support/ui'; +import { mockGetAllImages } from 'support/intercepts/images'; +import { mockCreateLinode } from 'support/intercepts/linodes'; import { interceptGetStackScripts, mockGetStackScript, mockGetStackScripts, } from 'support/intercepts/stackscripts'; -import { mockCreateLinode } from 'support/intercepts/linodes'; +import { ui } from 'support/ui'; import { getRandomOCAId } from 'support/util/one-click-apps'; import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; + +import { imageFactory, linodeFactory } from 'src/factories'; import { stackScriptFactory } from 'src/factories/stackscripts'; -import { oneClickApps } from 'src/features/OneClickApps/oneClickApps'; import { getMarketplaceAppLabel } from 'src/features/Linodes/LinodeCreate/Tabs/Marketplace/utilities'; +import { oneClickApps } from 'src/features/OneClickApps/oneClickApps'; import type { StackScript } from '@linode/api-v4'; -import { imageFactory, linodeFactory } from 'src/factories'; -import { mockGetAllImages } from 'support/intercepts/images'; describe('OneClick Apps (OCA)', () => { it('Lists all the OneClick Apps', () => { @@ -85,11 +86,10 @@ describe('OneClick Apps (OCA)', () => { cy.findAllByLabelText( `Info for "${getMarketplaceAppLabel(candidateStackScript.label)}"` ) + .as('qaInfoFor') .first() - .scrollIntoView() - .should('be.visible') - .should('be.enabled') - .click(); + .scrollIntoView(); + cy.get('@qaInfoFor').should('be.visible').should('be.enabled').click(); }); ui.drawer @@ -118,39 +118,39 @@ describe('OneClick Apps (OCA)', () => { }), ]; const stackscript = stackScriptFactory.build({ - id: getRandomOCAId(), - username: 'linode', - user_gravatar_id: '9d4d301385af69ceb7ad658aad09c142', - label: 'E2E Test App', + created: '2019-03-08T21:13:32', + deployments_active: 412, + deployments_total: 18854, description: 'Minecraft OCA', - ordinal: 10, - logo_url: 'assets/Minecraft.svg', + id: getRandomOCAId(), images: ['linode/debian11', 'linode/ubuntu24.04'], - deployments_total: 18854, - deployments_active: 412, is_public: true, + label: 'E2E Test App', + logo_url: 'assets/Minecraft.svg', mine: false, - created: '2019-03-08T21:13:32', - updated: '2023-09-26T15:00:45', + ordinal: 10, rev_note: 'remove maxplayers hard coded options [oca-707]', script: '#!/usr/bin/env bash\n', + updated: '2023-09-26T15:00:45', user_defined_fields: [ { - name: 'username', + example: 'lgsmuser', label: "The username for the Linode's non-root admin/SSH user(must be lowercase)", - example: 'lgsmuser', + name: 'username', }, { - name: 'password', - label: "The password for the Linode's non-root admin/SSH user", example: 'S3cuReP@s$w0rd', + label: "The password for the Linode's non-root admin/SSH user", + name: 'password', }, { - name: 'levelname', label: 'World Name', + name: 'levelname', }, ], + user_gravatar_id: '9d4d301385af69ceb7ad658aad09c142', + username: 'linode', }); const rootPassword = randomString(16); @@ -187,20 +187,18 @@ describe('OneClick Apps (OCA)', () => { "The username for the Linode's non-root admin/SSH user(must be lowercase) (required)" ) .should('be.visible') - .click() - .type(firstName); + .click(); + cy.focused().type(firstName); cy.findByLabelText( "The password for the Linode's non-root admin/SSH user (required)" ) .should('be.visible') - .click() - .type(password); + .click(); + cy.focused().type(password); - cy.findByLabelText('World Name (required)') - .should('be.visible') - .click() - .type(levelName); + cy.findByLabelText('World Name (required)').should('be.visible').click(); + cy.focused().type(levelName); // Check each field should persist when moving onto another field cy.findByLabelText( @@ -214,12 +212,12 @@ describe('OneClick Apps (OCA)', () => { cy.findByLabelText('World Name (required)').should('have.value', levelName); // Choose an image - cy.findByPlaceholderText('Choose an image') - .click() - .type('{downArrow}{enter}'); + cy.findByPlaceholderText('Choose an image').click(); + cy.focused().type('{downArrow}{enter}'); // Choose a region - ui.regionSelect.find().click().type(`${region.id}{enter}`); + ui.regionSelect.find().click(); + cy.focused().type(`${region.id}{enter}`); // Choose a Linode plan cy.get('[data-qa-plan-row="Dedicated 8 GB"]') @@ -229,10 +227,8 @@ describe('OneClick Apps (OCA)', () => { }); // Enter a label. - cy.findByText('Linode Label') - .should('be.visible') - .click() - .type(linodeLabel); + cy.findByText('Linode Label').should('be.visible').click(); + cy.focused().type(linodeLabel); // Choose a Root Password cy.get('[id="root-password"]').type(rootPassword); @@ -282,8 +278,10 @@ describe('OneClick Apps (OCA)', () => { cy.findAllByLabelText( `Info for "${getMarketplaceAppLabel(candidateStackScript.label)}"` ) + .as('qaInfoFor') .first() - .scrollIntoView() + .scrollIntoView(); + cy.get('@qaInfoFor') .should('be.visible') .should('be.enabled') .click(); diff --git a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts index cfb59e42e07..1f5383399ad 100644 --- a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts @@ -5,11 +5,12 @@ import { profileFactory, } from '@src/factories'; import { accountUserFactory } from '@src/factories/accountUsers'; +import { grantsFactory } from '@src/factories/grants'; import { DateTime } from 'luxon'; import { interceptGetInvoices, - interceptGetPayments, interceptGetPaymentMethods, + interceptGetPayments, mockCreateChildAccountToken, mockCreateChildAccountTokenError, mockGetAccount, @@ -31,7 +32,6 @@ import { mockGetRegions } from 'support/intercepts/regions'; import { ui } from 'support/ui'; import { assertLocalStorageValue } from 'support/util/local-storage'; import { randomLabel, randomNumber, randomString } from 'support/util/random'; -import { grantsFactory } from '@src/factories/grants'; /** * Confirms expected username and company name are shown in user menu button and yields the button. @@ -73,13 +73,13 @@ const mockParentAccount = accountFactory.build({ }); const mockParentProfile = profileFactory.build({ - username: randomLabel(), user_type: 'parent', + username: randomLabel(), }); const mockParentUser = accountUserFactory.build({ - username: mockParentProfile.username, user_type: 'parent', + username: mockParentProfile.username, }); const mockChildAccount = accountFactory.build({ @@ -92,25 +92,25 @@ const mockAlternateChildAccount = accountFactory.build({ }); const mockChildAccountProxyUser = accountUserFactory.build({ - username: mockParentProfile.username, user_type: 'proxy', + username: mockParentProfile.username, }); // Used for testing flows involving multiple children (e.g. switching child -> child). const mockAlternateChildAccountProxyUser = accountUserFactory.build({ - username: mockParentProfile.username, user_type: 'proxy', + username: mockParentProfile.username, }); const mockChildAccountProfile = profileFactory.build({ - username: mockChildAccountProxyUser.username, user_type: 'proxy', + username: mockChildAccountProxyUser.username, }); // Used for testing flows involving multiple children (e.g. switching child -> child). const mockAlternateChildAccountProfile = profileFactory.build({ - username: mockAlternateChildAccountProxyUser.username, user_type: 'proxy', + username: mockAlternateChildAccountProxyUser.username, }); const childAccountAccessGrantEnabled = grantsFactory.build({ @@ -122,26 +122,26 @@ const childAccountAccessGrantDisabled = grantsFactory.build({ }); const mockChildAccountToken = appTokenFactory.build({ - id: randomNumber(), created: DateTime.now().toISO(), expiry: DateTime.now().plus({ minutes: 15 }).toISO(), + id: randomNumber(), label: `${mockChildAccount.company}_proxy`, scopes: '*', + thumbnail_url: undefined, token: randomString(32), website: undefined, - thumbnail_url: undefined, }); // Used for testing flows involving multiple children (e.g. switching child -> child). const mockAlternateChildAccountToken = appTokenFactory.build({ - id: randomNumber(), created: DateTime.now().toISO(), expiry: DateTime.now().plus({ minutes: 15 }).toISO(), + id: randomNumber(), label: `${mockAlternateChildAccount.company}_proxy`, scopes: '*', + thumbnail_url: undefined, token: randomString(32), website: undefined, - thumbnail_url: undefined, }); const mockErrorMessage = 'An unknown error has occurred.'; @@ -350,7 +350,8 @@ describe('Parent/Child account switching', () => { // Confirm no results message. mockGetChildAccounts([]).as('getEmptySearchResults'); - cy.findByPlaceholderText('Search').click().type('Fake Name'); + cy.findByPlaceholderText('Search').click(); + cy.focused().type('Fake Name'); cy.wait('@getEmptySearchResults'); cy.contains(mockChildAccount.company).should('not.exist'); @@ -360,10 +361,9 @@ describe('Parent/Child account switching', () => { // Confirm filtering by company name displays only one search result. mockGetChildAccounts([mockChildAccount]).as('getSearchResults'); - cy.findByPlaceholderText('Search') - .click() - .clear() - .type(mockChildAccount.company); + cy.findByPlaceholderText('Search').click(); + cy.focused().clear(); + cy.focused().type(mockChildAccount.company); cy.wait('@getSearchResults'); cy.findByText(mockChildAccount.company).should('be.visible'); @@ -400,10 +400,10 @@ describe('Parent/Child account switching', () => { // data set to mock values. cy.visitWithLogin('/account/billing', { localStorageOverrides: { - proxy_user: true, - 'authentication/parent_token/token': `Bearer ${mockParentToken}`, 'authentication/parent_token/expire': mockParentExpiration, 'authentication/parent_token/scopes': '*', + 'authentication/parent_token/token': `Bearer ${mockParentToken}`, + proxy_user: true, }, }); @@ -487,10 +487,10 @@ describe('Parent/Child account switching', () => { // data set to mock values. cy.visitWithLogin('/account/billing', { localStorageOverrides: { - proxy_user: true, - 'authentication/parent_token/token': `Bearer ${mockParentToken}`, 'authentication/parent_token/expire': mockParentExpiration, 'authentication/parent_token/scopes': '*', + 'authentication/parent_token/token': `Bearer ${mockParentToken}`, + proxy_user: true, }, }); diff --git a/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts b/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts index 0c03b94d23e..7be2bb852ec 100644 --- a/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts @@ -1,30 +1,31 @@ -import { mockGetLinodes } from 'support/intercepts/linodes'; -import { - accountFactory, - accountUserFactory, - profileFactory, -} from 'src/factories'; -import { randomLabel, randomString } from 'support/util/random'; +import { DateTime } from 'luxon'; import { mockGetAccount, mockGetChildAccounts, } from 'support/intercepts/account'; +import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockGetProfile } from 'support/intercepts/profile'; -import { DateTime } from 'luxon'; import { ui } from 'support/ui'; +import { randomLabel, randomString } from 'support/util/random'; + +import { + accountFactory, + accountUserFactory, + profileFactory, +} from 'src/factories'; const mockChildAccount = accountFactory.build({ company: 'Partner Company', }); const mockChildAccountProxyUser = accountUserFactory.build({ - username: randomLabel(), user_type: 'proxy', + username: randomLabel(), }); const mockChildAccountProxyProfile = profileFactory.build({ - username: mockChildAccountProxyUser.username, user_type: 'proxy', + username: mockChildAccountProxyUser.username, }); describe('Parent/Child token expiration', () => { @@ -41,12 +42,12 @@ describe('Parent/Child token expiration', () => { // Mock local storage parent token expiry to have already passed. cy.visitWithLogin('/', { localStorageOverrides: { - proxy_user: true, - 'authentication/parent_token/token': `Bearer ${randomString(32)}`, 'authentication/parent_token/expire': DateTime.local() .minus({ minutes: 30 }) .toISO(), 'authentication/parent_token/scopes': '*', + 'authentication/parent_token/token': `Bearer ${randomString(32)}`, + proxy_user: true, }, }); @@ -77,6 +78,6 @@ describe('Parent/Child token expiration', () => { .click(); }); - cy.url().should('endWith', '/logout'); + cy.url().should('endWith', '/login'); }); }); diff --git a/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts b/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts index ddfbdab9566..48ab9549bc6 100644 --- a/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts @@ -24,13 +24,13 @@ const mockParentAccount = accountFactory.build({ }); const mockParentProfile = profileFactory.build({ - username: randomLabel(), user_type: 'parent', + username: randomLabel(), }); const mockParentUser = accountUserFactory.build({ - username: mockParentProfile.username, user_type: 'parent', + username: mockParentProfile.username, }); const mockChildAccount = accountFactory.build({ @@ -38,14 +38,14 @@ const mockChildAccount = accountFactory.build({ }); const mockParentAccountToken = appTokenFactory.build({ - id: randomNumber(), created: DateTime.now().toISO(), expiry: DateTime.now().plus({ minutes: 15 }).toISO(), + id: randomNumber(), label: `${mockParentAccount.company}_proxy`, scopes: '*', + thumbnail_url: undefined, token: randomString(32), website: undefined, - thumbnail_url: undefined, }); describe('Token scopes', () => { @@ -91,12 +91,9 @@ describe('Token scopes', () => { ); // Specify a label and re-submit. - cy.findByLabelText('Label') - .scrollIntoView() - .should('be.visible') - .should('be.enabled') - .click() - .type(mockParentAccountToken.label); + cy.findByLabelText('Label').as('qaLabel').scrollIntoView(); + cy.get('@qaLabel').should('be.visible').should('be.enabled').click(); + cy.focused().type(mockParentAccountToken.label); ui.buttonGroup .findButtonByTitle('Create Token') @@ -160,8 +157,9 @@ describe('Token scopes', () => { .within(() => { // Confirm that the “Child account access” grant is not visible in the list of permissions. cy.findAllByText('Child Account Access') - .scrollIntoView() - .should('be.visible'); + .as('qaChildAccount') + .scrollIntoView(); + cy.get('@qaChildAccount').should('be.visible'); // Specify ALL scopes by selecting the "No Access" Select All radio button. cy.get('[data-qa-perm-rw-radio]').click(); @@ -172,12 +170,9 @@ describe('Token scopes', () => { ); // Specify a label and re-submit. - cy.findByLabelText('Label') - .scrollIntoView() - .should('be.visible') - .should('be.enabled') - .click() - .type(mockParentAccountToken.label); + cy.findByLabelText('Label').as('qaLabel').scrollIntoView(); + cy.get('@qaLabel').should('be.visible').should('be.enabled').click(); + cy.focused().type(mockParentAccountToken.label); ui.buttonGroup .findButtonByTitle('Create Token') 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 2484db5c322..3acbd05ace0 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 @@ -1,42 +1,43 @@ import { mockGetAccount } from 'support/intercepts/account'; -import { - accountFactory, - linodeFactory, - placementGroupFactory, -} from 'src/factories'; -import { regionFactory } from 'src/factories'; -import { ui } from 'support/ui/'; import { mockCreateLinode, mockGetLinodeDetails, } from 'support/intercepts/linodes'; -import { mockGetRegions } from 'support/intercepts/regions'; import { mockCreatePlacementGroup, mockGetPlacementGroups, } from 'support/intercepts/placement-groups'; -import { randomNumber, randomString } from 'support/util/random'; -import { CANNOT_CHANGE_PLACEMENT_GROUP_POLICY_MESSAGE } from 'src/features/PlacementGroups/constants'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui/'; import { linodeCreatePage } from 'support/ui/pages'; +import { randomNumber, randomString } from 'support/util/random'; import { extendRegion } from 'support/util/regions'; +import { + accountFactory, + linodeFactory, + placementGroupFactory, +} from 'src/factories'; +import { regionFactory } from 'src/factories'; +import { CANNOT_CHANGE_PLACEMENT_GROUP_POLICY_MESSAGE } from 'src/features/PlacementGroups/constants'; + const mockAccount = accountFactory.build(); const mockNewarkRegion = extendRegion( regionFactory.build({ capabilities: ['Linodes', 'Placement Group'], + country: 'us', id: 'us-east', label: 'Newark, NJ', - country: 'us', }) ); const mockDallasRegion = extendRegion( regionFactory.build({ capabilities: ['Linodes'], + country: 'us', id: 'us-central', label: 'Dallas, TX', - country: 'us', }) ); @@ -109,11 +110,11 @@ describe('Linode create flow with Placement Group', () => { .click(); const mockPlacementGroup = placementGroupFactory.build({ + is_compliant: true, label: 'pg-1-us-east', - region: mockRegions[0].id, - placement_group_type: 'anti_affinity:local', placement_group_policy: 'strict', - is_compliant: true, + placement_group_type: 'anti_affinity:local', + region: mockRegions[0].id, }); mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); @@ -173,20 +174,22 @@ describe('Linode create flow with Placement Group', () => { const linodeLabel = 'linode-with-placement-group'; const mockLinode = linodeFactory.build({ label: linodeLabel, - region: mockRegions[0].id, placement_group: { id: mockPlacementGroup.id, }, + region: mockRegions[0].id, }); // Confirm the Placement group assignment is accounted for in the summary. cy.findByText('Assigned to Placement Group') - .scrollIntoView() - .should('be.visible'); + .as('qaAssigned') + .scrollIntoView(); + cy.get('@qaAssigned').should('be.visible'); // Type in a label, password and submit the form. mockCreateLinode(mockLinode).as('createLinode'); - cy.get('#linode-label').clear().type('linode-with-placement-group'); + cy.get('#linode-label').clear(); + cy.focused().type('linode-with-placement-group'); cy.get('#root-password').type(randomString(32)); cy.findByText('Create Linode').should('be.enabled').click(); @@ -210,21 +213,21 @@ describe('Linode create flow with Placement Group', () => { */ it('can assign existing Placement Group during Linode Create flow', () => { const mockPlacementGroup = placementGroupFactory.build({ + is_compliant: true, label: 'pg-1-us-east', - region: mockRegions[0].id, - placement_group_type: 'anti_affinity:local', placement_group_policy: 'strict', - is_compliant: true, + placement_group_type: 'anti_affinity:local', + region: mockRegions[0].id, }); const linodeLabel = 'linode-with-placement-group'; const mockLinode = linodeFactory.build({ id: randomNumber(), label: linodeLabel, - region: mockRegions[0].id, placement_group: { id: mockPlacementGroup.id, }, + region: mockRegions[0].id, }); mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); @@ -242,9 +245,8 @@ describe('Linode create flow with Placement Group', () => { // Confirm that mocked Placement Group is shown in the Autocomplete, and then select it. cy.findByText( `Placement Groups in ${mockNewarkRegion.label} (${mockNewarkRegion.id})` - ) - .click() - .type(`${mockPlacementGroup.label}`); + ).click(); + cy.focused().type(`${mockPlacementGroup.label}`); ui.autocompletePopper .findByTitle(mockPlacementGroup.label) .should('be.visible') @@ -252,8 +254,9 @@ describe('Linode create flow with Placement Group', () => { // Confirm the Placement group assignment is accounted for in the summary. cy.findByText('Assigned to Placement Group') - .scrollIntoView() - .should('be.visible'); + .as('qaAssigned') + .scrollIntoView(); + cy.get('@qaAssigned').should('be.visible'); // Create Linode and confirm contents of outgoing API request payload. ui.button 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 e114bf576de..a56e60fe1e9 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,15 +1,15 @@ import { mockGetAccount } from 'support/intercepts/account'; -import { accountFactory, placementGroupFactory } from 'src/factories'; -import { regionFactory } from 'src/factories'; -import { ui } from 'support/ui/'; -import { mockGetRegions } from 'support/intercepts/regions'; import { mockCreatePlacementGroup, mockGetPlacementGroups, } from 'support/intercepts/placement-groups'; +import { mockGetRegions } from 'support/intercepts/regions'; +import { ui } from 'support/ui/'; import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { accountFactory, placementGroupFactory } from 'src/factories'; +import { regionFactory } from 'src/factories'; import { CANNOT_CHANGE_PLACEMENT_GROUP_POLICY_MESSAGE } from 'src/features/PlacementGroups/constants'; const mockAccount = accountFactory.build(); @@ -26,9 +26,6 @@ describe('Placement Group create flow', () => { */ it('can create Placement Group', () => { const mockRegions = regionFactory.buildList(5, { - placement_group_limits: { - maximum_pgs_per_customer: randomNumber(), - }, capabilities: [ 'Linodes', 'NodeBalancers', @@ -40,19 +37,22 @@ describe('Placement Group create flow', () => { 'Vlans', 'Premium Plans', ], + placement_group_limits: { + maximum_pgs_per_customer: randomNumber(), + }, }); const mockPlacementGroupRegion = chooseRegion({ - regions: mockRegions, capabilities: ['Placement Group'], + regions: mockRegions, }); const mockPlacementGroup = placementGroupFactory.build({ + is_compliant: true, label: randomLabel(), - region: mockPlacementGroupRegion.id, - placement_group_type: 'anti_affinity:local', placement_group_policy: 'strict', - is_compliant: true, + placement_group_type: 'anti_affinity:local', + region: mockPlacementGroupRegion.id, }); const placementGroupLimitMessage = `Maximum placement groups in region: ${mockPlacementGroupRegion.placement_group_limits.maximum_pgs_per_customer}`; @@ -83,9 +83,8 @@ describe('Placement Group create flow', () => { // Enter label, select region, and submit form. cy.findByLabelText('Label').type(mockPlacementGroup.label); - cy.findByLabelText('Region') - .click() - .type(`${mockPlacementGroupRegion.label}{enter}`); + cy.findByLabelText('Region').click(); + cy.focused().type(`${mockPlacementGroupRegion.label}{enter}`); cy.findByText(placementGroupLimitMessage).should('be.visible'); cy.findByText(CANNOT_CHANGE_PLACEMENT_GROUP_POLICY_MESSAGE).should( 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 f2e121e73f5..93e8c050573 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 @@ -3,25 +3,26 @@ */ import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockDeletePlacementGroup, + mockDeletePlacementGroupError, mockGetPlacementGroup, mockGetPlacementGroups, mockUnassignPlacementGroupLinodes, - mockDeletePlacementGroupError, mockUnassignPlacementGroupLinodesError, } from 'support/intercepts/placement-groups'; +import { ui } from 'support/ui'; +import { buildArray } from 'support/util/arrays'; +import { randomLabel, randomNumber } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + import { accountFactory, linodeFactory, placementGroupFactory, } from 'src/factories'; import { headers as emptyStatePageHeaders } from 'src/features/PlacementGroups/PlacementGroupsLanding/PlacementGroupsLandingEmptyStateData'; -import { randomLabel, randomNumber } from 'support/util/random'; -import { chooseRegion } from 'support/util/regions'; -import { ui } from 'support/ui'; -import { buildArray } from 'support/util/arrays'; -import { mockGetLinodes } from 'support/intercepts/linodes'; // Mock an account with 'Placement Group' capability. const mockAccount = accountFactory.build(); @@ -56,10 +57,10 @@ describe('Placement Group deletion', () => { const mockPlacementGroupRegion = chooseRegion(); const mockPlacementGroup = placementGroupFactory.build({ id: randomNumber(), + is_compliant: true, label: randomLabel(), members: [], region: mockPlacementGroupRegion.id, - is_compliant: true, }); mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); @@ -143,8 +144,8 @@ describe('Placement Group deletion', () => { // Linodes that are assigned to the Placement Group being deleted. const mockPlacementGroupLinodes = buildArray(3, () => linodeFactory.build({ - label: randomLabel(), id: randomNumber(), + label: randomLabel(), region: mockPlacementGroupRegion.id, }) ); @@ -152,22 +153,22 @@ describe('Placement Group deletion', () => { // Placement Group that will be deleted. const mockPlacementGroup = placementGroupFactory.build({ id: randomNumber(), + is_compliant: true, label: randomLabel(), members: mockPlacementGroupLinodes.map((linode) => ({ - linode_id: linode.id, is_compliant: true, + linode_id: linode.id, })), region: mockPlacementGroupRegion.id, - is_compliant: true, }); // Second unrelated Placement Group to verify landing page content after deletion. const secondMockPlacementGroup = placementGroupFactory.build({ id: randomNumber(), + is_compliant: true, label: randomLabel(), members: [], region: mockPlacementGroupRegion.id, - is_compliant: true, }); mockGetLinodes(mockPlacementGroupLinodes).as('getLinodes'); @@ -317,10 +318,10 @@ describe('Placement Group deletion', () => { const mockPlacementGroupRegion = chooseRegion(); const mockPlacementGroup = placementGroupFactory.build({ id: randomNumber(), + is_compliant: true, label: randomLabel(), members: [], region: mockPlacementGroupRegion.id, - is_compliant: true, }); mockGetPlacementGroups([mockPlacementGroup]).as('getPlacementGroups'); @@ -419,8 +420,8 @@ describe('Placement Group deletion', () => { // Linodes that are assigned to the Placement Group being deleted. const mockPlacementGroupLinodes = buildArray(3, () => linodeFactory.build({ - label: randomLabel(), id: randomNumber(), + label: randomLabel(), region: mockPlacementGroupRegion.id, }) ); @@ -428,22 +429,22 @@ describe('Placement Group deletion', () => { // Placement Group that will be deleted. const mockPlacementGroup = placementGroupFactory.build({ id: randomNumber(), + is_compliant: true, label: randomLabel(), members: mockPlacementGroupLinodes.map((linode) => ({ - linode_id: linode.id, is_compliant: true, + linode_id: linode.id, })), region: mockPlacementGroupRegion.id, - is_compliant: true, }); // Second unrelated Placement Group to verify landing page content after deletion. const secondMockPlacementGroup = placementGroupFactory.build({ id: randomNumber(), + is_compliant: true, label: randomLabel(), members: [], region: mockPlacementGroupRegion.id, - is_compliant: true, }); mockGetLinodes(mockPlacementGroupLinodes).as('getLinodes'); 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 244f629f834..6e910ff4cd2 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,14 +1,15 @@ +import { mockGetAccount } from 'support/intercepts/account'; +import { mockGetLinodes } from 'support/intercepts/linodes'; import { mockGetPlacementGroups } from 'support/intercepts/placement-groups'; import { ui } from 'support/ui'; +import { randomLabel, randomNumber } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + import { accountFactory, linodeFactory, placementGroupFactory, } from 'src/factories'; -import { mockGetAccount } from 'support/intercepts/account'; -import { randomLabel, randomNumber } from 'support/util/random'; -import { chooseRegion } from 'support/util/regions'; -import { mockGetLinodes } from 'support/intercepts/linodes'; const mockAccount = accountFactory.build(); @@ -49,31 +50,31 @@ describe('VM Placement landing page', () => { const mockPlacementGroupNoncompliantRegion = chooseRegion(); const mockPlacementGroupLinode = linodeFactory.build({ - label: randomLabel(), id: randomNumber(), + label: randomLabel(), region: mockPlacementGroupNoncompliantRegion.id, }); const mockPlacementGroupCompliant = placementGroupFactory.build({ id: randomNumber(), - label: randomLabel(), - region: mockPlacementGroupCompliantRegion.id, - placement_group_type: 'anti_affinity:local', is_compliant: true, - placement_group_policy: 'flexible', + label: randomLabel(), members: [], + placement_group_policy: 'flexible', + placement_group_type: 'anti_affinity:local', + region: mockPlacementGroupCompliantRegion.id, }); const mockPlacementGroupNoncompliant = placementGroupFactory.build({ id: randomNumber(), - label: randomLabel(), - region: mockPlacementGroupNoncompliantRegion.id, - placement_group_type: 'affinity:local', is_compliant: false, - placement_group_policy: 'strict', + label: randomLabel(), members: [ - { linode_id: mockPlacementGroupLinode.id, is_compliant: false }, + { is_compliant: false, linode_id: mockPlacementGroupLinode.id }, ], + placement_group_policy: 'strict', + placement_group_type: 'affinity:local', + region: mockPlacementGroupNoncompliantRegion.id, }); const mockPlacementGroups = [ 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 2f25e62d576..46c161c0882 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 @@ -1,9 +1,3 @@ -import { - accountFactory, - linodeFactory, - placementGroupFactory, - regionFactory, -} from 'src/factories'; import { mockGetAccount } from 'support/intercepts/account'; import { mockGetLinodeDetails, @@ -22,6 +16,14 @@ import { ui } from 'support/ui'; import { buildArray } from 'support/util/arrays'; import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; + +import { + accountFactory, + linodeFactory, + placementGroupFactory, + regionFactory, +} from 'src/factories'; + import type { Linode } from '@linode/api-v4'; const mockAccount = accountFactory.build(); @@ -71,10 +73,10 @@ describe('Placement Groups Linode assignment', () => { const mockLinode = mockLinodes[0]; const mockPlacementGroup = placementGroupFactory.build({ + is_compliant: true, label: randomLabel(), - region: mockPlacementGroupRegion.id, members: [], - is_compliant: true, + region: mockPlacementGroupRegion.id, }); const mockPlacementGroupWithLinode = { @@ -190,22 +192,22 @@ describe('Placement Groups Linode assignment', () => { }); const mockPlacementGroup = placementGroupFactory.build({ + is_compliant: true, label: randomLabel(), members: [], - region: mockPlacementGroupRegion.id, - is_compliant: true, placement_group_policy: 'flexible', + region: mockPlacementGroupRegion.id, }); const mockPlacementGroupAfterAssignment = { ...mockPlacementGroup, + is_compliant: false, members: [ { - linode_id: mockLinode.id, is_compliant: false, + linode_id: mockLinode.id, }, ], - is_compliant: false, }; const complianceWarning = `Placement Group ${mockPlacementGroup.label} is non-compliant. We are working to resolve compliance issues so that you can continue assigning Linodes to this Placement Group.`; @@ -296,11 +298,11 @@ describe('Placement Groups Linode assignment', () => { }); const mockPlacementGroup = placementGroupFactory.build({ + is_compliant: true, label: randomLabel(), members: [], - region: mockPlacementGroupRegion.id, - is_compliant: true, placement_group_policy: 'strict', + region: mockPlacementGroupRegion.id, }); const complianceErrorMessage = `Assignment would break Placement Group's compliance, non compliant Linode IDs: [${mockLinode.id}]`; @@ -378,18 +380,18 @@ describe('Placement Groups Linode assignment', () => { const mockLinodeRemaining = mockLinodes[1]; const mockPlacementGroup = placementGroupFactory.build({ + is_compliant: true, label: randomLabel(), - region: mockPlacementGroupRegion.id, members: mockLinodes.map((linode: Linode) => ({ - linode_id: linode.id, is_compliant: true, + linode_id: linode.id, })), - is_compliant: true, + region: mockPlacementGroupRegion.id, }); const mockPlacementGroupAfterUnassignment = { ...mockPlacementGroup, - members: [{ linode_id: mockLinodeRemaining.id, is_compliant: true }], + members: [{ is_compliant: true, linode_id: mockLinodeRemaining.id }], }; mockGetRegions(mockRegions); 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 f863e7aa8cc..084f1b6cf29 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 @@ -3,9 +3,10 @@ */ import { mockGetAccount } from 'support/intercepts/account'; -import { accountFactory } from 'src/factories'; import { ui } from 'support/ui'; +import { accountFactory } from 'src/factories'; + const mockAccount = accountFactory.build(); describe('Placement Groups navigation', () => { 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 de6c903c887..2c45e3ddbd5 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,17 +2,18 @@ * @file Integration tests for Placement Group update label flows. */ -import { randomLabel, randomNumber } from 'support/util/random'; +import { mockGetAccount } from 'support/intercepts/account'; import { mockGetPlacementGroup, mockGetPlacementGroups, mockUpdatePlacementGroup, mockUpdatePlacementGroupError, } from 'support/intercepts/placement-groups'; -import { accountFactory, placementGroupFactory } from 'src/factories'; -import { mockGetAccount } from 'support/intercepts/account'; -import { chooseRegion } from 'support/util/regions'; import { ui } from 'support/ui'; +import { randomLabel, randomNumber } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +import { accountFactory, placementGroupFactory } from 'src/factories'; const mockAccount = accountFactory.build(); @@ -35,12 +36,12 @@ describe('Placement Group update label flow', () => { const mockPlacementGroup = placementGroupFactory.build({ id: randomNumber(), - label: randomLabel(), - region: mockPlacementGroupCompliantRegion.id, - placement_group_type: 'anti_affinity:local', is_compliant: true, - placement_group_policy: 'flexible', + label: randomLabel(), members: [], + placement_group_policy: 'flexible', + placement_group_type: 'anti_affinity:local', + region: mockPlacementGroupCompliantRegion.id, }); const mockPlacementGroupUpdated = { @@ -75,8 +76,10 @@ describe('Placement Group update label flow', () => { cy.findByText('Edit').should('be.visible'); cy.findByDisplayValue(mockPlacementGroup.label) .should('be.visible') - .click() - .type(`{selectall}{backspace}${mockPlacementGroupUpdated.label}`); + .click(); + cy.focused().type( + `{selectall}{backspace}${mockPlacementGroupUpdated.label}` + ); cy.findByText('Edit').should('be.visible').click(); @@ -102,12 +105,12 @@ describe('Placement Group update label flow', () => { const mockPlacementGroup = placementGroupFactory.build({ id: randomNumber(), - label: randomLabel(), - region: mockPlacementGroupCompliantRegion.id, - placement_group_type: 'anti_affinity:local', is_compliant: true, - placement_group_policy: 'flexible', + label: randomLabel(), members: [], + placement_group_policy: 'flexible', + placement_group_type: 'anti_affinity:local', + region: mockPlacementGroupCompliantRegion.id, }); const mockPlacementGroupUpdated = { @@ -140,8 +143,10 @@ describe('Placement Group update label flow', () => { cy.findByText('Edit').should('be.visible'); cy.findByDisplayValue(mockPlacementGroup.label) .should('be.visible') - .click() - .type(`{selectall}{backspace}${mockPlacementGroupUpdated.label}`); + .click(); + cy.focused().type( + `{selectall}{backspace}${mockPlacementGroupUpdated.label}` + ); cy.findByText('Edit').should('be.visible').click(); diff --git a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts index 98992a90d5e..6cef52543ac 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/create-stackscripts.spec.ts @@ -1,26 +1,28 @@ +import { createImage, getLinodeDisks, resizeLinodeDisk } from '@linode/api-v4'; import { authenticate } from 'support/api/authentication'; -import { - pollLinodeStatus, - pollImageStatus, - pollLinodeDiskSize, -} from 'support/util/polling'; -import { randomLabel, randomString, randomPhrase } from 'support/util/random'; import { interceptGetAccountAvailability } from 'support/intercepts/account'; +import { interceptGetAllImages } from 'support/intercepts/images'; +import { interceptCreateLinode } from 'support/intercepts/linodes'; import { interceptCreateStackScript, interceptGetStackScripts, } from 'support/intercepts/stackscripts'; -import { interceptCreateLinode } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; -import { createLinodeRequestFactory } from 'src/factories'; -import { createImage, getLinodeDisks, resizeLinodeDisk } from '@linode/api-v4'; -import { chooseRegion, getRegionByLabel } from 'support/util/regions'; import { SimpleBackoffMethod } from 'support/util/backoff'; import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; -import { interceptGetAllImages } from 'support/intercepts/images'; -import type { Image } from '@linode/api-v4'; +import { + pollImageStatus, + pollLinodeDiskSize, + pollLinodeStatus, +} from 'support/util/polling'; +import { randomLabel, randomPhrase, randomString } from 'support/util/random'; +import { chooseRegion, getRegionByLabel } from 'support/util/regions'; + import { getFilteredImagesForImageSelect } from 'src/components/ImageSelect/utilities'; +import { createLinodeRequestFactory } from 'src/factories'; + +import type { Image } from '@linode/api-v4'; // StackScript fixture paths. const stackscriptBasicPath = 'stackscripts/stackscript-basic.sh'; @@ -70,14 +72,12 @@ const fillOutStackscriptForm = ( // Fill out "StackScript Label", "Description", "Target Images", and "Script" fields. cy.findByLabelText(/^StackScript Label.*/) .should('be.visible') - .click() - .type(label); + .click(); + cy.focused().type(label); if (description) { - cy.findByLabelText('Description') - .should('be.visible') - .click() - .type(description); + cy.findByLabelText('Description').should('be.visible').click(); + cy.focused().type(description); } ui.autocomplete.findByLabel('Target Images').should('be.visible').click(); @@ -108,11 +108,9 @@ const fillOutLinodeForm = (label: string, regionName: string) => { .click(); ui.regionSelect.find().should('have.value', `${region.label} (${region.id})`); - cy.findByText('Linode Label') - .should('be.visible') - .click() - .type('{selectall}{backspace}') - .type(label); + cy.findByText('Linode Label').should('be.visible').click(); + cy.focused().type('{selectall}{backspace}'); + cy.focused().type(label); cy.findByText('Dedicated CPU').should('be.visible').click(); cy.get('[id="g6-dedicated-2"]').click(); @@ -133,11 +131,11 @@ const createLinodeAndImage = async () => { const resizedDiskSize = 2048; const linode = await createTestLinode( createLinodeRequestFactory.build({ + booted: false, label: randomLabel(), region: chooseRegion().id, root_pass: randomString(32), type: 'g6-nanode-1', - booted: false, }) ); @@ -267,16 +265,12 @@ describe('Create stackscripts', () => { // Fill out Linode creation form, confirm UDF fields behave as expected. fillOutLinodeForm(linodeLabel, linodeRegion.label); - cy.findByLabelText('Example Password') - .should('be.visible') - .click() - .type(randomString(32)); + cy.findByLabelText('Example Password').should('be.visible').click(); + cy.focused().type(randomString(32)); - cy.findByLabelText('Example Title') - .should('be.visible') - .click() - .type('{selectall}{backspace}') - .type(randomString(12)); + cy.findByLabelText('Example Title').should('be.visible').click(); + cy.focused().type('{selectall}{backspace}'); + cy.focused().type(randomString(12)); ui.button .findByTitle('Create Linode') @@ -367,18 +361,16 @@ describe('Create stackscripts', () => { filteredImageData?.forEach((imageSample: Image) => { const imageLabel = imageSample.label; cy.findAllByText(imageLabel, { exact: false }) + .as('qaImageLabel') .last() - .scrollIntoView() - .should('exist') - .should('be.visible'); + .scrollIntoView(); + cy.get('@qaImageLabel').should('exist').should('be.visible'); }); }); // Select private image. - cy.findByText(privateImage.label) - .scrollIntoView() - .should('be.visible') - .click(); + cy.findByText(privateImage.label).as('qaPrivateImage').scrollIntoView(); + cy.get('@qaPrivateImage').should('be.visible').click(); interceptCreateLinode().as('createLinode'); fillOutLinodeForm( diff --git a/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts index 4738d91e962..cd0aef158e3 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/delete-stackscripts.spec.ts @@ -1,11 +1,13 @@ import { authenticate } from 'support/api/authentication'; -import { stackScriptFactory } from 'src/factories'; import { mockDeleteStackScript, + mockGetStackScript, mockGetStackScripts, } from 'support/intercepts/stackscripts'; import { ui } from 'support/ui'; +import { stackScriptFactory } from 'src/factories'; + authenticate(); describe('Delete stackscripts', () => { /* @@ -19,6 +21,9 @@ describe('Delete stackscripts', () => { is_public: false, }); mockGetStackScripts(stackScripts).as('getStackScripts'); + mockGetStackScript(stackScripts[0].id, stackScripts[0]).as( + 'getStackScript' + ); cy.visitWithLogin('/stackscripts/account'); cy.wait('@getStackScripts'); @@ -32,6 +37,7 @@ describe('Delete stackscripts', () => { .click(); }); ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + cy.wait('@getStackScript'); ui.dialog .findByTitle(`Delete StackScript ${stackScripts[0].label}?`) .should('be.visible') @@ -57,6 +63,9 @@ describe('Delete stackscripts', () => { }); mockDeleteStackScript(stackScripts[0].id).as('deleteStackScript'); mockGetStackScripts([stackScripts[1]]).as('getUpdatedStackScripts'); + mockGetStackScript(stackScripts[1].id, stackScripts[1]).as( + 'getUpdatedStackScript' + ); ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); ui.dialog .findByTitle(`Delete StackScript ${stackScripts[0].label}?`) @@ -69,7 +78,6 @@ describe('Delete stackscripts', () => { }); cy.wait('@deleteStackScript'); cy.wait('@getUpdatedStackScripts'); - cy.findByText(stackScripts[0].label).should('not.exist'); // The "Automate Deployment with StackScripts!" welcome page appears when no StackScript exists. @@ -84,6 +92,7 @@ describe('Delete stackscripts', () => { mockDeleteStackScript(stackScripts[1].id).as('deleteStackScript'); mockGetStackScripts([]).as('getUpdatedStackScripts'); ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); + cy.wait('@getUpdatedStackScript'); ui.dialog .findByTitle(`Delete StackScript ${stackScripts[1].label}?`) .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts index e87b60bb6f2..d6343ceeec4 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-community-stackscripts.spec.ts @@ -1,9 +1,4 @@ -import type { StackScript } from '@linode/api-v4'; -import { Profile, getProfile } from '@linode/api-v4'; - -import { stackScriptFactory } from 'src/factories'; -import { formatDate } from 'src/utilities/formatDate'; - +import { getProfile } from '@linode/api-v4'; import { authenticate } from 'support/api/authentication'; import { interceptCreateLinode } from 'support/intercepts/linodes'; import { mockGetUserPreferences } from 'support/intercepts/profile'; @@ -17,16 +12,19 @@ import { cleanUp } from 'support/util/cleanup'; import { randomLabel, randomString } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { stackScriptFactory } from 'src/factories'; +import { formatDate } from 'src/utilities/formatDate'; + +import type { Profile, StackScript } from '@linode/api-v4'; + const mockStackScripts: StackScript[] = [ stackScriptFactory.build({ - id: 443929, - username: 'litespeed', - user_gravatar_id: 'f7360fb588b5f65d81ee1afe11b6c0ad', - label: 'OpenLiteSpeed-WordPress', + created: '2019-05-23T16:21:41', + deployments_active: 238, + deployments_total: 4400, description: 'Blazing-fast WordPress with LSCache, 300+ times faster than regular WordPress\n\nOpenLiteSpeed is the Open Source edition of LiteSpeed Web Server Enterprise and contains all of the essential features. OLS provides enormous scalability, and an accelerated hosting platform for WordPress. \n\nWhole process maybe take up to 10 minutes to finish. ', - ordinal: 0, - logo_url: '', + id: 443929, images: [ 'linode/centos7', 'linode/debian9', @@ -43,45 +41,47 @@ const mockStackScripts: StackScript[] = [ 'linode/almalinux9', 'linode/rocky9', ], - deployments_total: 4400, - deployments_active: 238, is_public: true, + label: 'OpenLiteSpeed-WordPress', + logo_url: '', mine: false, - created: '2019-05-23T16:21:41', - updated: '2023-08-22T16:41:48', + ordinal: 0, rev_note: 'add more OS', script: '#!/bin/bash\n### linode\n### Install OpenLiteSpeed and WordPress\nbash <( curl -sk https://raw.githubusercontent.com/litespeedtech/ls-cloud-image/master/Setup/wpimgsetup.sh )\n### Regenerate password for Web Admin, Database, setup Welcome Message\nbash <( curl -sk https://raw.githubusercontent.com/litespeedtech/ls-cloud-image/master/Cloud-init/per-instance.sh )\n### Reboot server\nreboot\n', + updated: '2023-08-22T16:41:48', user_defined_fields: [], + user_gravatar_id: 'f7360fb588b5f65d81ee1afe11b6c0ad', + username: 'litespeed', }), stackScriptFactory.build({ - id: 68166, - username: 'serverok', - user_gravatar_id: '8c2562f63286df4f8aae5babe5920ade', - label: 'Squid Proxy Server', + created: '2017-02-07T02:28:49', + deployments_active: 13, + deployments_total: 35469, description: 'Auto setup Squid Proxy Server on Ubuntu 16.04 LTS', - ordinal: 0, - logo_url: '', + id: 68166, images: ['linode/ubuntu16.04lts'], - deployments_total: 35469, - deployments_active: 13, is_public: true, + label: 'Squid Proxy Server', + logo_url: '', mine: false, - created: '2017-02-07T02:28:49', - updated: '2023-08-07T02:34:15', + ordinal: 0, rev_note: 'Initial import', script: '#!/bin/bash\n# \n# \n# Squid Proxy Server\n# Author: admin@serverok.in\n# Blog: https://www.serverok.in\n\n\n/usr/bin/apt update\n/usr/bin/apt -y install apache2-utils squid3\n\n/usr/bin/htpasswd -b -c /etc/squid/passwd $SQUID_USER $SQUID_PASSWORD\n\n/bin/rm -f /etc/squid/squid.conf\n/usr/bin/touch /etc/squid/blacklist.acl\n/usr/bin/wget --no-check-certificate -O /etc/squid/squid.conf https://raw.githubusercontent.com/hostonnet/squid-proxy-installer/master/squid.conf\n\n/sbin/iptables -I INPUT -p tcp --dport 3128 -j ACCEPT\n/sbin/iptables-save\n\nservice squid restart\nupdate-rc.d squid defaults', + updated: '2023-08-07T02:34:15', user_defined_fields: [ { - name: 'squid_user', label: 'Proxy Username', + name: 'squid_user', }, { - name: 'squid_password', label: 'Proxy Password', + name: 'squid_password', }, ], + user_gravatar_id: '8c2562f63286df4f8aae5babe5920ade', + username: 'serverok', }), ]; @@ -110,13 +110,13 @@ describe('Community Stackscripts integration tests', () => { cy.defer(getProfile, 'getting profile').then((profile: Profile) => { const dateFormatOptionsLanding = { - timezone: profile.timezone, displayTime: false, + timezone: profile.timezone, }; const dateFormatOptionsDetails = { - timezone: profile.timezone, displayTime: true, + timezone: profile.timezone, }; const updatedTimeLanding = formatDate( @@ -137,9 +137,10 @@ describe('Community Stackscripts integration tests', () => { // Search the corresponding community stack script mockGetStackScripts([stackScript]).as('getFilteredStackScripts'); - cy.findByPlaceholderText('Search by Label, Username, or Description') - .click() - .type(`${stackScript.label}{enter}`); + cy.findByPlaceholderText( + 'Search by Label, Username, or Description' + ).click(); + cy.focused().type(`${stackScript.label}{enter}`); cy.wait('@getFilteredStackScripts'); // Check filtered results @@ -251,9 +252,10 @@ describe('Community Stackscripts integration tests', () => { cy.get('tr').then((value) => { const rowCount = Cypress.$(value).length - 1; // Remove the table title row - cy.findByPlaceholderText('Search by Label, Username, or Description') - .click() - .type(`${stackScript.label}{enter}`); + cy.findByPlaceholderText( + 'Search by Label, Username, or Description' + ).click(); + cy.focused().type(`${stackScript.label}{enter}`); cy.get(`[data-qa-table-row="${stackScript.label}"]`).should('be.visible'); cy.get('tr').its('length').should('be.lt', rowCount); @@ -285,9 +287,10 @@ describe('Community Stackscripts integration tests', () => { cy.visitWithLogin('/stackscripts/community'); cy.wait(['@getStackScripts', '@getPreferences']); - cy.findByPlaceholderText('Search by Label, Username, or Description') - .click() - .type(`${stackScriptName}{enter}`); + cy.findByPlaceholderText( + 'Search by Label, Username, or Description' + ).click(); + cy.focused().type(`${stackScriptName}{enter}`); cy.get(`[data-qa-table-row="${stackScriptName}"]`) .should('be.visible') .within(() => { @@ -325,18 +328,12 @@ describe('Community Stackscripts integration tests', () => { ); // Input VPN information - cy.get('[id="ipsec-pre-shared-key"]') - .should('be.visible') - .click() - .type(`${sharedKey}{enter}`); - cy.get('[id="vpn-username"]') - .should('be.visible') - .click() - .type(`${vpnUser}{enter}`); - cy.get('[id="vpn-password"]') - .should('be.visible') - .click() - .type(`${vpnPassword}{enter}`); + cy.get('[id="ipsec-pre-shared-key"]').should('be.visible').click(); + cy.focused().type(`${sharedKey}{enter}`); + cy.get('[id="vpn-username"]').should('be.visible').click(); + cy.focused().type(`${vpnUser}{enter}`); + cy.get('[id="vpn-password"]').should('be.visible').click(); + cy.focused().type(`${vpnPassword}{enter}`); // Check each field should persist when moving onto another field cy.get('[id="ipsec-pre-shared-key"]').should('have.value', sharedKey); @@ -344,10 +341,8 @@ describe('Community Stackscripts integration tests', () => { cy.get('[id="vpn-password"]').should('have.value', vpnPassword); // Choose an image - cy.findByPlaceholderText('Choose an image') - .should('be.visible') - .click() - .type(image); + cy.findByPlaceholderText('Choose an image').should('be.visible').click(); + cy.focused().type(image); ui.autocompletePopper.findByTitle(image).should('be.visible').click(); cy.findByText(image).should('be.visible').click(); @@ -360,7 +355,8 @@ describe('Community Stackscripts integration tests', () => { .click(); // An error message shows up when no region is selected cy.contains('Region is required.').should('be.visible'); - ui.regionSelect.find().click().type(`${region.id}{enter}`); + ui.regionSelect.find().click(); + cy.focused().type(`${region.id}{enter}`); // Choose a plan ui.button @@ -370,11 +366,9 @@ describe('Community Stackscripts integration tests', () => { .click(); // Enter a label. - cy.findByText('Linode Label') - .should('be.visible') - .click() - .type('{selectAll}{backspace}') - .type(linodeLabel); + cy.findByText('Linode Label').should('be.visible').click(); + cy.focused().type('{selectAll}{backspace}'); + cy.focused().type(linodeLabel); // An error message shows up when no region is selected cy.contains('Plan is required.').should('be.visible'); @@ -386,7 +380,8 @@ describe('Community Stackscripts integration tests', () => { // Input root password // Weak or fair root password cannot rebuild the linode - cy.get('[id="root-password"]').clear().type(weakPassword); + cy.get('[id="root-password"]').clear(); + cy.focused().type(weakPassword); ui.button .findByTitle('Create Linode') .should('be.visible') @@ -397,7 +392,8 @@ describe('Community Stackscripts integration tests', () => { 'be.visible' ); - cy.get('[id="root-password"]').clear().type(fairPassword); + cy.get('[id="root-password"]').clear(); + cy.focused().type(fairPassword); ui.button .findByTitle('Create Linode') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts index ee132f35ea3..8c57536150b 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/smoke-stackscripts-landing-page.spec.ts @@ -1,8 +1,9 @@ import { authenticate } from 'support/api/authentication'; -import { stackScriptFactory } from 'src/factories'; import { mockGetStackScripts } from 'support/intercepts/stackscripts'; import { ui } from 'support/ui'; +import { stackScriptFactory } from 'src/factories'; + authenticate(); describe('Display stackscripts', () => { /* diff --git a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts index 02b4274fc2a..f3bee63a668 100644 --- a/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts +++ b/packages/manager/cypress/e2e/core/stackscripts/update-stackscripts.spec.ts @@ -1,17 +1,17 @@ +import { getImages } from '@linode/api-v4'; +import { stackScriptFactory } from '@src/factories'; import { authenticate } from 'support/api/authentication'; -import { randomLabel, randomPhrase } from 'support/util/random'; import { mockGetStackScript, + mockGetStackScripts, mockUpdateStackScript, mockUpdateStackScriptError, - mockGetStackScripts, } from 'support/intercepts/stackscripts'; import { ui } from 'support/ui'; -import { stackScriptFactory } from '@src/factories'; -import { getImages, StackScript } from '@linode/api-v4'; import { depaginate } from 'support/util/paginate'; +import { randomLabel, randomPhrase } from 'support/util/random'; -import type { Image } from '@linode/api-v4'; +import type { Image, StackScript } from '@linode/api-v4'; // StackScript fixture paths. const stackscriptNoShebangPath = 'stackscripts/stackscript-no-shebang.sh'; @@ -60,16 +60,14 @@ const fillOutStackscriptForm = ( // Fill out "StackScript Label", "Description", "Target Images", and "Script" fields. cy.findByLabelText(/^StackScript Label.*/) .should('be.visible') - .click() - .clear() - .type(label); + .click(); + cy.focused().clear(); + cy.focused().type(label); if (description) { - cy.findByLabelText('Description') - .should('be.visible') - .click() - .clear() - .type(description); + cy.findByLabelText('Description').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(description); } ui.autocomplete.findByLabel('Target Images').should('be.visible').click(); @@ -110,8 +108,8 @@ describe('Update stackscripts', () => { // Spread operator clones an object... { ...stackScripts[0], - label: stackscriptLabel, description: stackscriptDesc, + label: stackscriptLabel, }, { ...stackScripts[1] }, ]; @@ -212,6 +210,9 @@ describe('Update stackscripts', () => { is_public: false, }); mockGetStackScripts(stackScripts).as('getStackScripts'); + mockGetStackScript(stackScripts[0].id, stackScripts[0]).as( + 'getStackScript' + ); cy.visitWithLogin('/stackscripts/account'); cy.wait('@getStackScripts'); @@ -253,6 +254,7 @@ describe('Update stackscripts', () => { .findByTitle('Make StackScript Public') .should('be.visible') .click(); + cy.wait('@getStackScript'); const updatedStackScript = { ...stackScripts[0] }; updatedStackScript.is_public = true; mockUpdateStackScript(updatedStackScript.id, updatedStackScript).as( 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 41c53259a98..e52fe6cc512 100644 --- a/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/attach-volume.spec.ts @@ -1,18 +1,20 @@ import { createVolume } from '@linode/api-v4/lib/volumes'; -import { Linode, Volume } from '@linode/api-v4'; -import { createLinodeRequestFactory } from 'src/factories/linodes'; -import { volumeRequestPayloadFactory } from 'src/factories/volume'; import { authenticate } from 'support/api/authentication'; +import { interceptGetLinodeConfigs } from 'support/intercepts/configs'; import { interceptAttachVolume, // interceptDetachVolume, } from 'support/intercepts/volumes'; -import { randomLabel, randomString } from 'support/util/random'; import { ui } from 'support/ui'; -import { chooseRegion } from 'support/util/regions'; -import { interceptGetLinodeConfigs } from 'support/intercepts/configs'; import { cleanUp } from 'support/util/cleanup'; import { createTestLinode } from 'support/util/linodes'; +import { randomLabel, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +import { createLinodeRequestFactory } from 'src/factories/linodes'; +import { volumeRequestPayloadFactory } from 'src/factories/volume'; + +import type { Linode, Volume } from '@linode/api-v4'; // Local storage override to force volume table to list up to 100 items. // This is a workaround while we wait to get stuck volumes removed. @@ -66,10 +68,10 @@ describe('volume attach and detach flows', () => { }); const linodeRequest = createLinodeRequestFactory.build({ + booted: false, label: randomLabel(), region: commonRegion.id, root_pass: randomString(32), - booted: false, }); const entityPromise = Promise.all([ @@ -100,10 +102,8 @@ describe('volume attach and detach flows', () => { .click(); ui.drawer.findByTitle(`Attach Volume ${volume.label}`).within(() => { - cy.findByLabelText('Linode') - .should('be.visible') - .click() - .type(linode.label); + cy.findByLabelText('Linode').should('be.visible').click(); + cy.focused().type(linode.label); ui.autocompletePopper .findByTitle(linode.label) diff --git a/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts index 146233cae6c..8288734266a 100644 --- a/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/clone-volume.spec.ts @@ -1,11 +1,13 @@ -import { Volume } from '@linode/api-v4'; -import { volumeRequestPayloadFactory } from 'src/factories/volume'; import { authenticate } from 'support/api/authentication'; +import { createActiveVolume } from 'support/api/volumes'; import { interceptCloneVolume } from 'support/intercepts/volumes'; import { cleanUp } from 'support/util/cleanup'; import { randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { createActiveVolume } from 'support/api/volumes'; + +import { volumeRequestPayloadFactory } from 'src/factories/volume'; + +import type { Volume } from '@linode/api-v4'; // Local storage override to force volume table to list up to 100 items. // This is a workaround while we wait to get stuck volumes removed. @@ -62,7 +64,8 @@ describe('volume clone flow', () => { .closest('[data-qa-drawer="true"]') .should('be.visible') .within(() => { - cy.findByText('Label').click().type(cloneVolumeLabel); + cy.findByText('Label').click(); + cy.focused().type(cloneVolumeLabel); cy.get('[data-qa-buttons="true"]').within(() => { cy.findByText('Clone Volume').should('be.visible').click(); }); diff --git a/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts index 0ca5c8ce4e2..c4aecfc2eaa 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.smoke.spec.ts @@ -1,24 +1,24 @@ /* eslint-disable sonarjs/no-duplicate-string */ import { - volumeFactory, linodeFactory, + volumeFactory, volumeTypeFactory, } from '@src/factories'; import { - mockGetLinodes, mockGetLinodeDetails, mockGetLinodeVolumes, + mockGetLinodes, } from 'support/intercepts/linodes'; import { mockCreateVolume, - mockGetVolume, - mockGetVolumes, mockDetachVolume, - mockGetVolumeTypesError, + mockGetVolume, mockGetVolumeTypes, + mockGetVolumeTypesError, + mockGetVolumes, } from 'support/intercepts/volumes'; -import { randomLabel, randomNumber } from 'support/util/random'; import { ui } from 'support/ui'; +import { randomLabel, randomNumber } from 'support/util/random'; import { PRICES_RELOAD_ERROR_NOTICE_TEXT, @@ -62,14 +62,14 @@ const validateBasicVolume = ( // This is a workaround for accounts that have volumes unrelated to tests. // @TODO Remove preference override when volumes are removed from test accounts. const preferenceOverrides = { - linodes_view_style: 'list', - linodes_group_by_tag: false, - volumes_group_by_tag: false, desktop_sidebar_open: false, + linodes_group_by_tag: false, + linodes_view_style: 'list', sortKeys: { 'linodes-landing': { order: 'asc', orderBy: 'label' }, volume: { order: 'desc', orderBy: 'label' }, }, + volumes_group_by_tag: false, }; // Local storage override to force volume table to list up to 100 items. @@ -90,8 +90,8 @@ describe('volumes', () => { mockGetVolumeTypes(mockVolumeTypes).as('getVolumeTypes'); cy.visitWithLogin('/volumes', { - preferenceOverrides, localStorageOverrides, + preferenceOverrides, }); ui.button.findByTitle('Create Volume').should('be.visible').click(); @@ -103,10 +103,8 @@ describe('volumes', () => { ui.button.findByTitle('Create Volume').should('be.visible').click(); cy.findByText('Label is required.').should('be.visible'); - cy.findByLabelText('Label', { exact: false }) - .should('be.visible') - .click() - .type(mockVolume.label); + cy.findByLabelText('Label', { exact: false }).should('be.visible').click(); + cy.focused().type(mockVolume.label); ui.button.findByTitle('Create Volume').should('be.visible').click(); @@ -129,12 +127,12 @@ describe('volumes', () => { it('creates volume from linode details', () => { const mockLinode = linodeFactory.build({ - label: randomLabel(), id: randomNumber(), + label: randomLabel(), }); const newVolume = volumeFactory.build({ - linode_id: mockLinode.id, label: randomLabel(), + linode_id: mockLinode.id, }); mockCreateVolume(newVolume).as('createVolume'); @@ -143,8 +141,8 @@ describe('volumes', () => { mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes'); cy.visitWithLogin('/linodes', { - preferenceOverrides, localStorageOverrides, + preferenceOverrides, }); // Visit a Linode's details page. @@ -166,7 +164,8 @@ describe('volumes', () => { .should('be.visible') .within(() => { cy.findByText('Create and Attach Volume').should('be.visible').click(); - cy.get('[data-qa-volume-label]').click().type(newVolume.label); + cy.get('[data-qa-volume-label]').click(); + cy.focused().type(newVolume.label); ui.button.findByTitle('Create Volume').should('be.visible').click(); }); @@ -197,8 +196,8 @@ describe('volumes', () => { mockGetVolumes([mockAttachedVolume]).as('getAttachedVolumes'); mockGetVolume(mockAttachedVolume).as('getVolume'); cy.visitWithLogin('/volumes', { - preferenceOverrides, localStorageOverrides, + preferenceOverrides, }); cy.wait('@getAttachedVolumes'); @@ -218,10 +217,8 @@ describe('volumes', () => { .findByTitle(`Detach Volume ${mockAttachedVolume.label}?`) .should('be.visible') .within(() => { - cy.findByLabelText('Volume Label') - .should('be.visible') - .click() - .type(mockAttachedVolume.label); + cy.findByLabelText('Volume Label').should('be.visible').click(); + cy.focused().type(mockAttachedVolume.label); ui.button .findByTitle('Detach') @@ -243,8 +240,8 @@ describe('volumes', () => { mockGetVolumeTypesError().as('getVolumeTypesError'); cy.visitWithLogin('/volumes', { - preferenceOverrides, localStorageOverrides, + preferenceOverrides, }); ui.button.findByTitle('Create Volume').should('be.visible').click(); @@ -267,8 +264,8 @@ describe('volumes', () => { it('does not allow creation of a volume with invalid pricing from linode details', () => { const mockLinode = linodeFactory.build({ - label: randomLabel(), id: randomNumber(), + label: randomLabel(), }); const newVolume = volumeFactory.build({ label: randomLabel(), @@ -282,8 +279,8 @@ describe('volumes', () => { mockGetVolumeTypesError().as('getVolumeTypesError'); cy.visitWithLogin('/linodes', { - preferenceOverrides, localStorageOverrides, + preferenceOverrides, }); // Visit a Linode's details page. diff --git a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts index ceac6fc7cf1..3fcdaad7eaf 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts @@ -1,27 +1,40 @@ -import type { Linode, Region } from '@linode/api-v4'; -import { createTestLinode } from 'support/util/linodes'; import { - createLinodeRequestFactory, - linodeFactory, -} from 'src/factories/linodes'; + accountUserFactory, + grantsFactory, + profileFactory, +} from '@src/factories'; import { authenticate } from 'support/api/authentication'; -import { cleanUp } from 'support/util/cleanup'; +import { entityTag } from 'support/constants/cypress'; +import { mockGetUser } from 'support/intercepts/account'; +import { mockGetAccount } from 'support/intercepts/account'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { + mockGetLinodeDetails, + mockGetLinodes, +} from 'support/intercepts/linodes'; +import { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; +import { mockGetRegions } from 'support/intercepts/regions'; import { interceptCreateVolume, mockGetVolume, mockGetVolumes, } from 'support/intercepts/volumes'; -import { randomNumber, randomString, randomLabel } from 'support/util/random'; -import { chooseRegion } from 'support/util/regions'; import { ui } from 'support/ui'; -import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; +import { cleanUp } from 'support/util/cleanup'; +import { createTestLinode } from 'support/util/linodes'; +import { randomLabel, randomNumber, randomString } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + import { accountFactory, regionFactory, volumeFactory } from 'src/factories'; -import { mockGetAccount } from 'support/intercepts/account'; -import { mockGetRegions } from 'support/intercepts/regions'; import { - mockGetLinodeDetails, - mockGetLinodes, -} from 'support/intercepts/linodes'; + createLinodeRequestFactory, + linodeFactory, +} from 'src/factories/linodes'; + +import type { Linode, Region } from '@linode/api-v4'; // Local storage override to force volume table to list up to 100 items. // This is a workaround while we wait to get stuck volumes removed. @@ -51,6 +64,7 @@ describe('volume create flow', () => { /* * - Creates a volume that is not attached to a Linode. * - Confirms that volume is listed correctly on volumes landing page. + * - Add a single tag to the volume during creation. */ it('creates an unattached volume', () => { cy.tag('purpose:syntheticTesting', 'method:e2e', 'purpose:dcTesting'); @@ -58,9 +72,9 @@ describe('volume create flow', () => { const region = chooseRegion(); const volume = { label: randomLabel(), - size: `${randomNumber(10, 250)}`, region: region.id, regionLabel: region.label, + size: `${randomNumber(10, 250)}`, }; interceptCreateVolume().as('createVolume'); @@ -70,8 +84,12 @@ describe('volume create flow', () => { }); // Fill out and submit volume create form. - cy.contains('Label').click().type(volume.label); - cy.contains('Size').click().type(`{selectall}{backspace}${volume.size}`); + cy.contains('Label').click(); + cy.focused().type(volume.label); + cy.findByLabelText('Tags').click(); + cy.focused().type(entityTag); + cy.contains('Size').click(); + cy.focused().type(`{selectall}{backspace}${volume.size}`); ui.regionSelect.find().click().type(`${volume.region}{enter}`); cy.findByText('Create Volume').click(); @@ -102,17 +120,17 @@ describe('volume create flow', () => { const region = chooseRegion(); const linodeRequest = createLinodeRequestFactory.build({ + booted: false, label: randomLabel(), region: region.id, root_pass: randomString(16), - booted: false, }); const volume = { label: randomLabel(), - size: `${randomNumber(10, 250)}`, region: region.id, regionLabel: region.label, + size: `${randomNumber(10, 250)}`, }; cy.defer(() => createTestLinode(linodeRequest), 'creating Linode').then( @@ -124,16 +142,14 @@ describe('volume create flow', () => { }); // Fill out and submit volume create form. - cy.contains('Label').click().type(volume.label); - cy.contains('Size') - .click() - .type(`{selectall}{backspace}${volume.size}`); + cy.contains('Label').click(); + cy.focused().type(volume.label); + cy.contains('Size').click(); + cy.focused().type(`{selectall}{backspace}${volume.size}`); ui.regionSelect.find().click().type(`${volume.region}{enter}`); - cy.findByLabelText('Linode') - .should('be.visible') - .click() - .type(linode.label); + cy.findByLabelText('Linode').should('be.visible').click(); + cy.focused().type(linode.label); ui.autocompletePopper .findByTitle(linode.label) @@ -194,10 +210,10 @@ describe('volume create flow', () => { mockGetRegions(mockRegions).as('getRegions'); const linodeRequest = createLinodeRequestFactory.build({ + booted: false, label: randomLabel(), - root_pass: randomString(16), region: mockRegions[0].id, - booted: false, + root_pass: randomString(16), }); cy.defer(() => createTestLinode(linodeRequest), 'creating Linode').then( @@ -206,10 +222,8 @@ describe('volume create flow', () => { cy.wait(['@getFeatureFlags', '@getAccount']); // Select a linode without the BSE capability - cy.findByLabelText('Linode') - .should('be.visible') - .click() - .type(linode.label); + cy.findByLabelText('Linode').should('be.visible').click(); + cy.focused().type(linode.label); ui.autocompletePopper .findByTitle(linode.label) @@ -250,9 +264,9 @@ describe('volume create flow', () => { // Mock linode const mockLinode = linodeFactory.build({ - region: mockRegions[0].id, - id: 123456, capabilities: ['Block Storage Encryption'], + id: 123456, + region: mockRegions[0].id, }); mockGetAccount(mockAccount).as('getAccount'); @@ -264,10 +278,8 @@ describe('volume create flow', () => { cy.wait(['@getAccount', '@getRegions', '@getLinodes']); // Select a linode without the BSE capability - cy.findByLabelText('Linode') - .should('be.visible') - .click() - .type(mockLinode.label); + cy.findByLabelText('Linode').should('be.visible').click(); + cy.focused().type(mockLinode.label); ui.autocompletePopper .findByTitle(mockLinode.label) @@ -307,15 +319,15 @@ describe('volume create flow', () => { mockGetRegions(mockRegions).as('getRegions'); const volume = volumeFactory.build({ - region: mockRegions[0].id, encryption: 'enabled', + region: mockRegions[0].id, }); const linodeRequest = createLinodeRequestFactory.build({ + booted: false, label: randomLabel(), - root_pass: randomString(16), region: mockRegions[0].id, - booted: false, + root_pass: randomString(16), }); cy.defer(() => createTestLinode(linodeRequest), 'creating Linode').then( @@ -350,8 +362,8 @@ describe('volume create flow', () => { // Ensure notice is displayed in "Attach Existing Volume" view when an encrypted volume is selected, & that the "Attach Volume" button is disabled cy.findByPlaceholderText('Select a Volume') .should('be.visible') - .click() - .type(`${volume.label}{downarrow}{enter}`); + .click(); + cy.focused().type(`${volume.label}{downarrow}{enter}`); ui.autocompletePopper .findByTitle(volume.label) .should('be.visible') @@ -374,10 +386,10 @@ describe('volume create flow', () => { it('creates a volume from an existing Linode', () => { cy.tag('method:e2e'); const linodeRequest = createLinodeRequestFactory.build({ + booted: false, label: randomLabel(), - root_pass: randomString(16), region: chooseRegion().id, - booted: false, + root_pass: randomString(16), }); cy.defer(() => createTestLinode(linodeRequest), 'creating Linode').then( @@ -398,7 +410,8 @@ describe('volume create flow', () => { 'be.visible' ); cy.contains('Create and Attach Volume').click(); - cy.contains('Label').click().type(volume.label); + cy.contains('Label').click(); + cy.focused().type(volume.label); cy.contains('Size').type(`{selectall}{backspace}${volume.size}`); cy.findByText('Create Volume').click(); }); @@ -430,4 +443,59 @@ describe('volume create flow', () => { } ); }); + + it('does not allow creation of a volume for restricted users from volume create page', () => { + // Mock setup for user profile, account user, and user grants with restricted permissions, + // simulating a default user without the ability to add Linodes. + const mockProfile = profileFactory.build({ + restricted: true, + username: randomLabel(), + }); + + const mockUser = accountUserFactory.build({ + restricted: true, + user_type: 'default', + username: mockProfile.username, + }); + + const mockGrants = grantsFactory.build({ + global: { + add_volumes: false, + }, + }); + + mockGetProfile(mockProfile); + mockGetProfileGrants(mockGrants); + mockGetUser(mockUser); + + cy.visitWithLogin('/volumes/create', { + localStorageOverrides: pageSizeOverride, + }); + + // Confirm that a notice should be shown informing the user they do not have permission to create a Linode. + cy.findByText( + "You don't have permissions to create this Volume. Please contact your account administrator to request the necessary permissions." + ).should('be.visible'); + + // Confirm that the "Label" field should be disabled. + cy.get('[id="label"]').should('be.visible').should('be.disabled'); + + // Confirm that the "Tags" field should be disabled. + cy.findByLabelText('Tags').should('be.visible').should('be.disabled'); + + // Confirm that the "Region" field should be disabled. + ui.regionSelect.find().should('be.visible').should('be.disabled'); + + // Confirm that the "Linode" field should be disabled. + cy.findByLabelText('Linode').should('be.visible').should('be.disabled'); + + // Confirm that the "Config" field should be disabled. + cy.findByLabelText('Config').should('be.visible').should('be.disabled'); + + // Confirm that the "Size" field should be disabled. + cy.get('[id="size"]').should('be.visible').should('be.disabled'); + + // Confirm that the "Create Volume" button is disabled. + cy.findByText('Create Volume').should('be.visible').should('be.disabled'); + }); }); 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 be6d7f8dec1..11792d1ee35 100644 --- a/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/delete-volume.spec.ts @@ -1,14 +1,17 @@ -import { createVolume, VolumeRequestPayload } from '@linode/api-v4/lib/volumes'; -import { Volume } from '@linode/api-v4'; -import { volumeRequestPayloadFactory } from 'src/factories/volume'; +import { createVolume } from '@linode/api-v4/lib/volumes'; import { authenticate } from 'support/api/authentication'; import { interceptDeleteVolume } from 'support/intercepts/volumes'; -import { randomLabel } from 'support/util/random'; import { ui } from 'support/ui'; -import { chooseRegion } from 'support/util/regions'; -import { cleanUp } from 'support/util/cleanup'; import { SimpleBackoffMethod } from 'support/util/backoff'; +import { cleanUp } from 'support/util/cleanup'; import { pollVolumeStatus } from 'support/util/polling'; +import { randomLabel } from 'support/util/random'; +import { chooseRegion } from 'support/util/regions'; + +import { volumeRequestPayloadFactory } from 'src/factories/volume'; + +import type { Volume } from '@linode/api-v4'; +import type { VolumeRequestPayload } from '@linode/api-v4/lib/volumes'; // Local storage override to force volume table to list up to 100 items. // This is a workaround while we wait to get stuck volumes removed. @@ -90,10 +93,8 @@ describe('volume delete flow', () => { .findByTitle(`Delete Volume ${volume.label}?`) .should('be.visible') .within(() => { - cy.findByLabelText('Volume Label') - .should('be.visible') - .click() - .type(volume.label); + cy.findByLabelText('Volume Label').should('be.visible').click(); + cy.focused().type(volume.label); ui.buttonGroup .findButtonByTitle('Delete') diff --git a/packages/manager/cypress/e2e/core/volumes/landing-page-empty-state.spec.ts b/packages/manager/cypress/e2e/core/volumes/landing-page-empty-state.spec.ts index 1d254586082..75c7ab29e33 100644 --- a/packages/manager/cypress/e2e/core/volumes/landing-page-empty-state.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/landing-page-empty-state.spec.ts @@ -1,5 +1,5 @@ -import { ui } from 'support/ui'; import { mockGetVolumes } from 'support/intercepts/volumes'; +import { ui } from 'support/ui'; describe('confirms Volumes landing page empty state is shown when no Volumes exist', () => { /* diff --git a/packages/manager/cypress/e2e/core/volumes/resize-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/resize-volume.spec.ts index 74b9955ea76..a34ca3cc7ec 100644 --- a/packages/manager/cypress/e2e/core/volumes/resize-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/resize-volume.spec.ts @@ -1,7 +1,4 @@ -import type { VolumeRequestPayload } from '@linode/api-v4'; import { createVolume } from '@linode/api-v4'; -import { Volume } from '@linode/api-v4'; -import { volumeRequestPayloadFactory } from 'src/factories/volume'; import { authenticate } from 'support/api/authentication'; import { interceptResizeVolume } from 'support/intercepts/volumes'; import { SimpleBackoffMethod } from 'support/util/backoff'; @@ -10,6 +7,11 @@ import { pollVolumeStatus } from 'support/util/polling'; import { randomLabel, randomNumber } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; +import { volumeRequestPayloadFactory } from 'src/factories/volume'; + +import type { VolumeRequestPayload } from '@linode/api-v4'; +import type { Volume } from '@linode/api-v4'; + // Local storage override to force volume table to list up to 100 items. // This is a workaround while we wait to get stuck volumes removed. // @TODO Remove local storage override when stuck volumes are removed from test accounts. @@ -81,9 +83,8 @@ describe('volume resize flow', () => { cy.get('[data-qa-drawer="true"]') .should('be.visible') .within(() => { - cy.findByText('Size') - .click() - .type(`{selectall}{backspace}${newSize}`); + cy.findByText('Size').click(); + cy.focused().type(`{selectall}{backspace}${newSize}`); cy.get('[data-qa-buttons="true"]').within(() => { cy.findByText('Resize Volume').should('be.visible').click(); }); diff --git a/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts index fbf120ce824..5370154172f 100644 --- a/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/search-volumes.spec.ts @@ -1,9 +1,10 @@ import { createVolume } from '@linode/api-v4/lib/volumes'; -import { Volume } from '@linode/api-v4'; -import { ui } from 'support/ui'; import { authenticate } from 'support/api/authentication'; -import { randomLabel } from 'support/util/random'; +import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; +import { randomLabel } from 'support/util/random'; + +import type { Volume } from '@linode/api-v4'; authenticate(); describe('Search Volumes', () => { diff --git a/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts index 25b5e128405..9b1b021897c 100644 --- a/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/update-volume.spec.ts @@ -1,12 +1,13 @@ -import { Volume } from '@linode/api-v4'; - -import { volumeRequestPayloadFactory } from 'src/factories/volume'; import { authenticate } from 'support/api/authentication'; +import { createActiveVolume } from 'support/api/volumes'; +import { ui } from 'support/ui'; +import { cleanUp } from 'support/util/cleanup'; import { randomLabel } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; -import { cleanUp } from 'support/util/cleanup'; -import { ui } from 'support/ui'; -import { createActiveVolume } from 'support/api/volumes'; + +import { volumeRequestPayloadFactory } from 'src/factories/volume'; + +import type { Volume } from '@linode/api-v4'; authenticate(); describe('volume update flow', () => { @@ -55,10 +56,8 @@ describe('volume update flow', () => { // Enter new label, click "Save Changes". cy.get('[data-qa-drawer="true"]').within(() => { cy.findByText('Edit Volume').should('be.visible'); - cy.findByDisplayValue(volume.label) - .should('be.visible') - .click() - .type(`{selectall}{backspace}${newLabel}`); + cy.findByDisplayValue(volume.label).should('be.visible').click(); + cy.focused().type(`{selectall}{backspace}${newLabel}`); cy.findByText('Save Changes').should('be.visible').click(); }); @@ -123,8 +122,8 @@ describe('volume update flow', () => { cy.findByPlaceholderText('Type to choose or create a tag.') .should('be.visible') - .click() - .type(`${newTags.join('{enter}')}{enter}`); + .click(); + cy.focused().type(`${newTags.join('{enter}')}{enter}`); cy.findByText('Save Changes').should('be.visible').click(); }); 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 27f5e379b84..14c5f8b7b0f 100644 --- a/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/upgrade-volume.spec.ts @@ -11,9 +11,9 @@ import { mockGetLinodeVolumes, } from 'support/intercepts/linodes'; import { - mockMigrateVolumes, - mockGetVolumes, mockGetVolume, + mockGetVolumes, + mockMigrateVolumes, } from 'support/intercepts/volumes'; import { ui } from 'support/ui'; @@ -22,8 +22,8 @@ describe('volume upgrade/migration', () => { const volume = volumeFactory.build(); const migrationScheduledNotification = notificationFactory.build({ + entity: { id: volume.id, type: 'volume' }, type: 'volume_migration_scheduled', - entity: { type: 'volume', id: volume.id }, }); mockGetVolumes([volume]).as('getVolumes'); @@ -43,8 +43,8 @@ describe('volume upgrade/migration', () => { .click(); const migrationImminentNotification = notificationFactory.build({ + entity: { id: volume.id, type: 'volume' }, type: 'volume_migration_imminent', - entity: { type: 'volume', id: volume.id }, }); mockGetNotifications([migrationImminentNotification]).as( 'getNotifications' @@ -66,8 +66,8 @@ describe('volume upgrade/migration', () => { const mockStartedMigrationEvent = eventFactory.build({ action: 'volume_migrate', entity: { id: volume.id, type: 'volume' }, - status: 'started', percent_complete: percentage, + status: 'started', }); mockGetEvents([mockStartedMigrationEvent]).as('getEvents'); @@ -79,7 +79,7 @@ describe('volume upgrade/migration', () => { const mockFinishedMigrationEvent = eventFactory.build({ action: 'volume_migrate', - entity: { id: volume.id, type: 'volume', label: volume.label }, + entity: { id: volume.id, label: volume.label, type: 'volume' }, status: 'finished', }); @@ -103,8 +103,8 @@ describe('volume upgrade/migration', () => { }); const migrationScheduledNotification = notificationFactory.build({ + entity: { id: volume.id, type: 'volume' }, type: 'volume_migration_scheduled', - entity: { type: 'volume', id: volume.id }, }); mockGetVolumes([volume]).as('getVolumes'); @@ -130,8 +130,8 @@ describe('volume upgrade/migration', () => { cy.wait(['@getLinode', '@getLinodeVolumes']); const migrationImminentNotification = notificationFactory.build({ + entity: { id: volume.id, type: 'volume' }, type: 'volume_migration_imminent', - entity: { type: 'volume', id: volume.id }, }); mockGetNotifications([migrationImminentNotification]).as( 'getNotifications' @@ -157,8 +157,8 @@ describe('volume upgrade/migration', () => { const mockStartedMigrationEvent = eventFactory.build({ action: 'volume_migrate', entity: { id: volume.id, type: 'volume' }, - status: 'started', percent_complete: percentage, + status: 'started', }); mockGetEvents([mockStartedMigrationEvent]).as('getEvents'); @@ -170,7 +170,7 @@ describe('volume upgrade/migration', () => { const mockFinishedMigrationEvent = eventFactory.build({ action: 'volume_migrate', - entity: { id: volume.id, type: 'volume', label: volume.label }, + entity: { id: volume.id, label: volume.label, type: 'volume' }, status: 'finished', }); @@ -194,8 +194,8 @@ describe('volume upgrade/migration', () => { }); const migrationScheduledNotification = notificationFactory.build({ + entity: { id: volume.id, type: 'volume' }, type: 'volume_migration_scheduled', - entity: { type: 'volume', id: volume.id }, }); mockMigrateVolumes().as('migrateVolumes'); @@ -217,8 +217,8 @@ describe('volume upgrade/migration', () => { .click(); const migrationImminentNotification = notificationFactory.build({ + entity: { id: volume.id, type: 'volume' }, type: 'volume_migration_imminent', - entity: { type: 'volume', id: volume.id }, }); mockGetNotifications([migrationImminentNotification]).as( 'getNotifications' @@ -244,8 +244,8 @@ describe('volume upgrade/migration', () => { const mockStartedMigrationEvent = eventFactory.build({ action: 'volume_migrate', entity: { id: volume.id, type: 'volume' }, - status: 'started', percent_complete: percentage, + status: 'started', }); mockGetEvents([mockStartedMigrationEvent]).as('getEvents'); @@ -257,7 +257,7 @@ describe('volume upgrade/migration', () => { const mockFinishedMigrationEvent = eventFactory.build({ action: 'volume_migrate', - entity: { id: volume.id, type: 'volume', label: volume.label }, + entity: { id: volume.id, label: volume.label, type: 'volume' }, status: 'finished', }); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts index 31c9522f730..bfa81f820c7 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-create.spec.ts @@ -2,31 +2,33 @@ * @file Integration tests for VPC create flow. */ -import type { Subnet, VPC } from '@linode/api-v4'; import { - vpcFactory, - subnetFactory, linodeFactory, regionFactory, + subnetFactory, + vpcFactory, } from '@src/factories'; import { mockGetRegions } from 'support/intercepts/regions'; import { - mockCreateVPCError, mockCreateVPC, + mockCreateVPCError, mockGetSubnets, } from 'support/intercepts/vpc'; +import { ui } from 'support/ui'; +import { buildArray } from 'support/util/arrays'; import { - randomLabel, - randomPhrase, randomIp, + randomLabel, randomNumber, + randomPhrase, randomString, } from 'support/util/random'; -import { ui } from 'support/ui'; -import { buildArray } from 'support/util/arrays'; -import { getUniqueLinodesFromSubnets } from 'src/features/VPCs/utils'; import { extendRegion } from 'support/util/regions'; +import { getUniqueLinodesFromSubnets } from 'src/features/VPCs/utils'; + +import type { Subnet, VPC } from '@linode/api-v4'; + /** * Gets the "Add another Subnet" section with the given index. * @@ -53,9 +55,9 @@ describe('VPC create flow', () => { const mockSubnets: Subnet[] = buildArray(3, (index: number) => { return subnetFactory.build({ - label: randomLabel(), id: randomNumber(10000, 99999), ipv4: `${randomIp()}/${randomNumber(0, 32)}`, + label: randomLabel(), linodes: linodeFactory.buildList(index + 1), }); }); @@ -64,10 +66,10 @@ describe('VPC create flow', () => { const mockInvalidIpRange = `${randomIp()}/${randomNumber(33, 100)}`; const mockVpc: VPC = vpcFactory.build({ + description: randomPhrase(), id: randomNumber(10000, 99999), label: randomLabel(), region: mockVPCRegion.id, - description: randomPhrase(), subnets: mockSubnets, }); @@ -81,29 +83,25 @@ describe('VPC create flow', () => { cy.visitWithLogin('/vpcs/create'); cy.wait('@getRegions'); - ui.regionSelect.find().click().type(`${mockVPCRegion.label}{enter}`); + ui.regionSelect.find().click(); + cy.focused().type(`${mockVPCRegion.label}{enter}`); - cy.findByText('VPC Label').should('be.visible').click().type(mockVpc.label); + cy.findByText('VPC Label').should('be.visible').click(); + cy.focused().type(mockVpc.label); - cy.findByText('Description') - .should('be.visible') - .click() - .type(mockVpc.description); + cy.findByText('Description').should('be.visible').click(); + cy.focused().type(mockVpc.description); // Fill out the first Subnet. // Insert an invalid empty IP range to confirm client side validation. getSubnetNodeSection(0) .should('be.visible') .within(() => { - cy.findByText('Subnet Label') - .should('be.visible') - .click() - .type(mockSubnets[0].label); + cy.findByText('Subnet Label').should('be.visible').click(); + cy.focused().type(mockSubnets[0].label); - cy.findByText('Subnet IP Address Range') - .should('be.visible') - .click() - .type(`{selectAll}{backspace}`); + cy.findByText('Subnet IP Address Range').should('be.visible').click(); + cy.focused().type(`{selectAll}{backspace}`); }); ui.button @@ -115,11 +113,9 @@ describe('VPC create flow', () => { cy.findByText(ipValidationErrorMessage1).should('be.visible'); // Enter a random non-IP address string to further test client side validation. - cy.findByText('Subnet IP Address Range') - .should('be.visible') - .click() - .type(`{selectAll}{backspace}`) - .type(randomString(18)); + cy.findByText('Subnet IP Address Range').should('be.visible').click(); + cy.focused().type(`{selectAll}{backspace}`); + cy.focused().type(randomString(18)); ui.button .findByTitle('Create VPC') @@ -130,11 +126,9 @@ describe('VPC create flow', () => { cy.findByText(ipValidationErrorMessage2).should('be.visible'); // Enter a valid IP address with an invalid network prefix to further test client side validation. - cy.findByText('Subnet IP Address Range') - .should('be.visible') - .click() - .type(`{selectAll}{backspace}`) - .type(mockInvalidIpRange); + cy.findByText('Subnet IP Address Range').should('be.visible').click(); + cy.focused().type(`{selectAll}{backspace}`); + cy.focused().type(mockInvalidIpRange); ui.button .findByTitle('Create VPC') @@ -145,11 +139,9 @@ describe('VPC create flow', () => { cy.findByText(ipValidationErrorMessage2).should('be.visible'); // Replace invalid IP address range with valid range. - cy.findByText('Subnet IP Address Range') - .should('be.visible') - .click() - .type(`{selectAll}{backspace}`) - .type(mockSubnets[0].ipv4!); + cy.findByText('Subnet IP Address Range').should('be.visible').click(); + cy.focused().type(`{selectAll}{backspace}`); + cy.focused().type(mockSubnets[0].ipv4!); // Add another subnet that we will remove later. ui.button @@ -163,11 +155,9 @@ describe('VPC create flow', () => { getSubnetNodeSection(1) .should('be.visible') .within(() => { - cy.findByText('Subnet IP Address Range') - .should('be.visible') - .click() - .type(`{selectAll}{backspace}`) - .type(mockSubnetToDelete.ipv4!); + cy.findByText('Subnet IP Address Range').should('be.visible').click(); + cy.focused().type(`{selectAll}{backspace}`); + cy.focused().type(mockSubnetToDelete.ipv4!); }); ui.button @@ -206,16 +196,12 @@ describe('VPC create flow', () => { getSubnetNodeSection(index + 1) .should('be.visible') .within(() => { - cy.findByText('Subnet Label') - .should('be.visible') - .click() - .type(mockSubnet.label); - - cy.findByText('Subnet IP Address Range') - .should('be.visible') - .click() - .type(`{selectAll}{backspace}`) - .type(`${randomIp()}/${randomNumber(0, 32)}`); + cy.findByText('Subnet Label').should('be.visible').click(); + cy.focused().type(mockSubnet.label); + + cy.findByText('Subnet IP Address Range').should('be.visible').click(); + cy.focused().type(`{selectAll}{backspace}`); + cy.focused().type(`${randomIp()}/${randomNumber(0, 32)}`); }); }); @@ -281,10 +267,10 @@ describe('VPC create flow', () => { ); const mockVpc: VPC = vpcFactory.build({ + description: randomPhrase(), id: randomNumber(10000, 99999), label: randomLabel(), region: mockVPCRegion.id, - description: randomPhrase(), subnets: [], }); @@ -297,7 +283,8 @@ describe('VPC create flow', () => { ui.regionSelect.find().click().type(`${mockVPCRegion.label}{enter}`); - cy.findByText('VPC Label').should('be.visible').click().type(mockVpc.label); + cy.findByText('VPC Label').should('be.visible').click(); + cy.focused().type(mockVpc.label); // Remove the subnet. getSubnetNodeSection(0) diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts index 1ee95a8652c..affd4240eae 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-details-page.spec.ts @@ -1,30 +1,34 @@ import { - mockGetVPC, - mockGetVPCs, - mockDeleteVPC, - mockUpdateVPC, - mockCreateSubnet, - mockDeleteSubnet, - mockEditSubnet, - mockGetSubnets, -} from 'support/intercepts/vpc'; -import { mockGetLinodeConfigs } from 'support/intercepts/configs'; -import { mockGetLinodeDetails } from 'support/intercepts/linodes'; + linodeConfigInterfaceFactory, + linodeConfigInterfaceFactoryWithVPC, +} from '@linode/utilities'; import { - linodeFactory, linodeConfigFactory, - LinodeConfigInterfaceFactoryWithVPC, + linodeFactory, subnetFactory, vpcFactory, - LinodeConfigInterfaceFactory, } from '@src/factories'; +import { mockGetLinodeConfigs } from 'support/intercepts/configs'; +import { mockGetLinodeDetails } from 'support/intercepts/linodes'; +import { + mockCreateSubnet, + mockDeleteSubnet, + mockDeleteVPC, + mockEditSubnet, + mockGetSubnets, + mockGetVPC, + mockGetVPCs, + mockUpdateVPC, +} from 'support/intercepts/vpc'; +import { ui } from 'support/ui'; import { randomLabel, randomNumber, randomPhrase } from 'support/util/random'; -import { chooseRegion } from 'support/util/regions'; -import type { VPC } from '@linode/api-v4'; import { getRegionById } from 'support/util/regions'; -import { ui } from 'support/ui'; +import { chooseRegion } from 'support/util/regions'; + import { WARNING_ICON_UNRECOMMENDED_CONFIG } from 'src/features/VPCs/constants'; +import type { VPC } from '@linode/api-v4'; + describe('VPC details page', () => { /** * - Confirms that VPC details pages can be visited. @@ -36,12 +40,13 @@ describe('VPC details page', () => { const mockVPC: VPC = vpcFactory.build({ id: randomNumber(), label: randomLabel(), + region: chooseRegion().id, }); const mockVPCUpdated = { ...mockVPC, - label: randomLabel(), description: randomPhrase(), + label: randomLabel(), }; const vpcRegion = getRegionById(mockVPC.region); @@ -68,17 +73,13 @@ describe('VPC details page', () => { .findByTitle('Edit VPC') .should('be.visible') .within(() => { - cy.findByLabelText('Label') - .should('be.visible') - .click() - .clear() - .type(mockVPCUpdated.label); + cy.findByLabelText('Label').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(mockVPCUpdated.label); - cy.findByLabelText('Description') - .should('be.visible') - .click() - .clear() - .type(mockVPCUpdated.description); + cy.findByLabelText('Description').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(mockVPCUpdated.description); ui.button .findByTitle('Save') @@ -102,10 +103,8 @@ describe('VPC details page', () => { .findByTitle(`Delete VPC ${mockVPCUpdated.label}`) .should('be.visible') .within(() => { - cy.findByLabelText('VPC Label') - .should('be.visible') - .click() - .type(mockVPCUpdated.label); + cy.findByLabelText('VPC Label').should('be.visible').click(); + cy.focused().type(mockVPCUpdated.label); ui.button .findByTitle('Delete') @@ -139,6 +138,7 @@ describe('VPC details page', () => { const mockVPC = vpcFactory.build({ id: randomNumber(), label: randomLabel(), + region: chooseRegion().id, }); const mockVPCAfterSubnetCreation = vpcFactory.build({ @@ -167,10 +167,8 @@ describe('VPC details page', () => { .findByTitle('Create Subnet') .should('be.visible') .within(() => { - cy.findByText('Subnet Label') - .should('be.visible') - .click() - .type(mockSubnet.label); + cy.findByText('Subnet Label').should('be.visible').click(); + cy.focused().type(mockSubnet.label); cy.findByTestId('create-subnet-drawer-button') .should('be.visible') @@ -213,11 +211,9 @@ describe('VPC details page', () => { .findByTitle('Edit Subnet') .should('be.visible') .within(() => { - cy.findByLabelText('Label') - .should('be.visible') - .click() - .clear() - .type(mockEditedSubnet.label); + cy.findByLabelText('Label').should('be.visible').click(); + cy.focused().clear(); + cy.focused().type(mockEditedSubnet.label); cy.findByLabelText('Subnet IP Address Range') .should('be.visible') @@ -256,10 +252,8 @@ describe('VPC details page', () => { .findByTitle(`Delete Subnet ${mockEditedSubnet.label}`) .should('be.visible') .within(() => { - cy.findByLabelText('Subnet Label') - .should('be.visible') - .click() - .type(mockEditedSubnet.label); + cy.findByLabelText('Subnet Label').should('be.visible').click(); + cy.focused().type(mockEditedSubnet.label); ui.button .findByTitle('Delete') @@ -293,14 +287,14 @@ describe('VPC details page', () => { const mockSubnet = subnetFactory.build({ id: randomNumber(), + ipv4: '10.0.0.0/24', label: randomLabel(), linodes: [ { id: mockLinode.id, - interfaces: [{ id: mockInterfaceId, active: true }], + interfaces: [{ active: true, id: mockInterfaceId }], }, ], - ipv4: '10.0.0.0/24', }); const mockVPC = vpcFactory.build({ @@ -310,11 +304,11 @@ describe('VPC details page', () => { subnets: [mockSubnet], }); - const mockInterface = LinodeConfigInterfaceFactoryWithVPC.build({ - vpc_id: mockVPC.id, - subnet_id: mockSubnet.id, - primary: true, + const mockInterface = linodeConfigInterfaceFactoryWithVPC.build({ active: true, + primary: true, + subnet_id: mockSubnet.id, + vpc_id: mockVPC.id, }); const mockLinodeConfig = linodeConfigFactory.build({ @@ -349,14 +343,14 @@ describe('VPC details page', () => { const mockSubnet = subnetFactory.build({ id: randomNumber(), + ipv4: '10.0.0.0/24', label: randomLabel(), linodes: [ { id: mockLinode.id, - interfaces: [{ id: mockInterfaceId, active: true }], + interfaces: [{ active: true, id: mockInterfaceId }], }, ], - ipv4: '10.0.0.0/24', }); const mockVPC = vpcFactory.build({ @@ -366,12 +360,12 @@ describe('VPC details page', () => { subnets: [mockSubnet], }); - const mockInterface = LinodeConfigInterfaceFactoryWithVPC.build({ + const mockInterface = linodeConfigInterfaceFactoryWithVPC.build({ + active: true, id: mockInterfaceId, - vpc_id: mockVPC.id, - subnet_id: mockSubnet.id, primary: false, - active: true, + subnet_id: mockSubnet.id, + vpc_id: mockVPC.id, }); const mockLinodeConfig = linodeConfigFactory.build({ @@ -406,14 +400,14 @@ describe('VPC details page', () => { const mockSubnet = subnetFactory.build({ id: randomNumber(), + ipv4: '10.0.0.0/24', label: randomLabel(), linodes: [ { id: mockLinode.id, - interfaces: [{ id: mockInterfaceId, active: true }], + interfaces: [{ active: true, id: mockInterfaceId }], }, ], - ipv4: '10.0.0.0/24', }); const mockVPC = vpcFactory.build({ @@ -423,18 +417,18 @@ describe('VPC details page', () => { subnets: [mockSubnet], }); - const mockPrimaryInterface = LinodeConfigInterfaceFactory.build({ - primary: true, + const mockPrimaryInterface = linodeConfigInterfaceFactory.build({ active: false, + primary: true, purpose: 'public', }); - const mockInterface = LinodeConfigInterfaceFactoryWithVPC.build({ + const mockInterface = linodeConfigInterfaceFactoryWithVPC.build({ + active: true, id: mockInterfaceId, - vpc_id: mockVPC.id, - subnet_id: mockSubnet.id, primary: false, - active: true, + subnet_id: mockSubnet.id, + vpc_id: mockVPC.id, }); const mockLinodeConfig = linodeConfigFactory.build({ diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts index e2863a1a22d..d52b2d19575 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-landing-page.spec.ts @@ -1,14 +1,15 @@ +import { subnetFactory, vpcFactory } from '@src/factories'; import { - mockGetVPCs, + MOCK_DELETE_VPC_ERROR, mockDeleteVPC, mockDeleteVPCError, + mockGetVPCs, mockUpdateVPC, - MOCK_DELETE_VPC_ERROR, } from 'support/intercepts/vpc'; -import { subnetFactory, vpcFactory } from '@src/factories'; import { ui } from 'support/ui'; import { randomLabel, randomPhrase } from 'support/util/random'; import { chooseRegion, getRegionById } from 'support/util/regions'; + import { VPC_LABEL } from 'src/features/VPCs/constants'; // TODO Remove feature flag mocks when feature flag is removed from codebase. @@ -17,7 +18,9 @@ describe('VPC landing page', () => { * - Confirms that VPCs are listed on the VPC landing page. */ it('lists VPC instances', () => { - const mockVPCs = vpcFactory.buildList(5); + const mockVPCs = vpcFactory.buildList(5, { + region: chooseRegion().id, + }); mockGetVPCs(mockVPCs).as('getVPCs'); cy.visitWithLogin('/vpcs'); @@ -79,21 +82,21 @@ describe('VPC landing page', () => { it('can update and delete VPCs from VPC landing page', () => { const mockVPCs = [ vpcFactory.build({ + description: randomPhrase(), label: randomLabel(), region: chooseRegion().id, - description: randomPhrase(), }), vpcFactory.build({ + description: randomPhrase(), label: randomLabel(), region: chooseRegion().id, - description: randomPhrase(), }), ]; const mockUpdatedVPC = { ...mockVPCs[1], - label: randomLabel(), description: randomPhrase(), + label: randomLabel(), }; mockGetVPCs([mockVPCs[1]]).as('getVPCs'); @@ -119,14 +122,14 @@ describe('VPC landing page', () => { cy.findByLabelText('Label') .should('be.visible') .should('have.value', mockVPCs[1].label) - .clear() - .type(mockUpdatedVPC.label); + .clear(); + cy.focused().type(mockUpdatedVPC.label); cy.findByLabelText('Description') .should('be.visible') .should('have.value', mockVPCs[1].description) - .clear() - .type(mockUpdatedVPC.description); + .clear(); + cy.focused().type(mockUpdatedVPC.description); // TODO Add interactions/assertions for region selection once feature is available. ui.button @@ -183,10 +186,8 @@ describe('VPC landing page', () => { .findByTitle(`Delete VPC ${mockVPCs[0].label}`) .should('be.visible') .within(() => { - cy.findByLabelText('VPC Label') - .should('be.visible') - .click() - .type(mockVPCs[0].label); + cy.findByLabelText('VPC Label').should('be.visible').click(); + cy.focused().type(mockVPCs[0].label); ui.button .findByTitle('Delete') @@ -217,10 +218,8 @@ describe('VPC landing page', () => { .findByTitle(`Delete VPC ${mockVPCs[1].label}`) .should('be.visible') .within(() => { - cy.findByLabelText('VPC Label') - .should('be.visible') - .click() - .type(mockVPCs[1].label); + cy.findByLabelText('VPC Label').should('be.visible').click(); + cy.focused().type(mockVPCs[1].label); ui.button .findByTitle('Delete') @@ -277,10 +276,8 @@ describe('VPC landing page', () => { .findByTitle(`Delete VPC ${mockVPCs[0].label}`) .should('be.visible') .within(() => { - cy.findByLabelText('VPC Label') - .should('be.visible') - .click() - .type(mockVPCs[0].label); + cy.findByLabelText('VPC Label').should('be.visible').click(); + cy.focused().type(mockVPCs[0].label); ui.button .findByTitle('Delete') diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts index 4ad53ea2022..25a1a075727 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-linodes-update.spec.ts @@ -2,32 +2,33 @@ * @file Integration tests for VPC assign/unassign Linodes flows. */ +import { linodeConfigInterfaceFactoryWithVPC } from '@linode/utilities'; import { - mockGetSubnets, - mockCreateSubnet, - mockGetVPC, - mockGetVPCs, -} from 'support/intercepts/vpc'; -import { + linodeConfigFactory, + linodeFactory, subnetFactory, vpcFactory, - linodeFactory, - linodeConfigFactory, - LinodeConfigInterfaceFactoryWithVPC, } from '@src/factories'; -import { ui } from 'support/ui'; -import { randomNumber, randomLabel } from 'support/util/random'; -import { mockGetLinodes } from 'support/intercepts/linodes'; +import { + vpcAssignLinodeRebootNotice, + vpcUnassignLinodeRebootNotice, +} from 'support/constants/vpc'; import { mockCreateLinodeConfigInterfaces, - mockGetLinodeConfigs, mockDeleteLinodeConfigInterface, + mockGetLinodeConfigs, } from 'support/intercepts/configs'; +import { mockGetLinodes } from 'support/intercepts/linodes'; import { - vpcAssignLinodeRebootNotice, - vpcUnassignLinodeRebootNotice, -} from 'support/constants/vpc'; -import { VPC, Linode, Config } from '@linode/api-v4'; + mockCreateSubnet, + mockGetSubnets, + mockGetVPC, + mockGetVPCs, +} from 'support/intercepts/vpc'; +import { ui } from 'support/ui'; +import { randomLabel, randomNumber } from 'support/util/random'; + +import type { Config, Linode, VPC } from '@linode/api-v4'; describe('VPC assign/unassign flows', () => { let mockVPCs: VPC[]; @@ -100,10 +101,8 @@ describe('VPC assign/unassign flows', () => { .findByTitle('Create Subnet') .should('be.visible') .within(() => { - cy.findByText('Subnet Label') - .should('be.visible') - .click() - .type(mockSubnet.label); + cy.findByText('Subnet Label').should('be.visible').click(); + cy.focused().type(mockSubnet.label); cy.findByTestId('create-subnet-drawer-button') .should('be.visible') @@ -144,11 +143,9 @@ describe('VPC assign/unassign flows', () => { mockGetLinodeConfigs(mockLinode.id, [mockConfig]).as( 'getLinodeConfigs' ); - cy.findByLabelText('Linode') - .should('be.visible') - .click() - .type(mockLinode.label) - .should('have.value', mockLinode.label); + cy.findByLabelText('Linode').should('be.visible').click(); + cy.focused().type(mockLinode.label); + cy.focused().should('have.value', mockLinode.label); ui.autocompletePopper .findByTitle(mockLinode.label) @@ -211,9 +208,9 @@ describe('VPC assign/unassign flows', () => { subnets: [mockSubnet], }); - const vpcInterface = LinodeConfigInterfaceFactoryWithVPC.build({ - vpc_id: mockVPC.id, + const vpcInterface = linodeConfigInterfaceFactoryWithVPC.build({ subnet_id: mockSubnet.id, + vpc_id: mockVPC.id, }); const mockLinodeConfig = linodeConfigFactory.build({ interfaces: [vpcInterface], @@ -266,10 +263,8 @@ describe('VPC assign/unassign flows', () => { 'getLinodeConfigs' ); - cy.findByLabelText('Linodes') - .should('be.visible') - .click() - .type(mockLinode.label); + cy.findByLabelText('Linodes').should('be.visible').click(); + cy.focused().type(mockLinode.label); ui.autocompletePopper .findByTitle(mockLinode.label) @@ -290,10 +285,8 @@ describe('VPC assign/unassign flows', () => { mockGetLinodeConfigs(mockSecondLinode.id, [mockLinodeConfig]).as( 'getLinodeConfigs' ); - cy.findByText('Linodes') - .should('be.visible') - .click() - .type(mockSecondLinode.label); + cy.findByText('Linodes').should('be.visible').click(); + cy.focused().type(mockSecondLinode.label); cy.findByText(mockSecondLinode.label).should('be.visible').click(); cy.wait('@getLinodeConfigs'); diff --git a/packages/manager/cypress/e2e/core/vpc/vpc-navigation.spec.ts b/packages/manager/cypress/e2e/core/vpc/vpc-navigation.spec.ts index 38ecad7a0d9..41eabbada0f 100644 --- a/packages/manager/cypress/e2e/core/vpc/vpc-navigation.spec.ts +++ b/packages/manager/cypress/e2e/core/vpc/vpc-navigation.spec.ts @@ -2,8 +2,8 @@ * @file Integration tests for VPC navigation. */ -import { ui } from 'support/ui'; import { mockGetUserPreferences } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; describe('VPC navigation', () => { /* diff --git a/packages/manager/cypress/e2e/region/images/create-machine-image-from-linode.spec.ts b/packages/manager/cypress/e2e/region/images/create-machine-image-from-linode.spec.ts deleted file mode 100644 index 5b883defa24..00000000000 --- a/packages/manager/cypress/e2e/region/images/create-machine-image-from-linode.spec.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { Disk, Linode } from '@linode/api-v4'; -import { createTestLinode } from 'support/util/linodes'; -import { createLinodeRequestFactory } from '@src/factories'; -import { authenticate } from 'support/api/authentication'; -import { imageCaptureProcessingTimeout } from 'support/constants/images'; -import { ui } from 'support/ui'; -import { cleanUp } from 'support/util/cleanup'; -import { randomLabel, randomPhrase, randomString } from 'support/util/random'; -import { testRegions } from 'support/util/regions'; - -authenticate(); -describe('Capture Machine Images', () => { - before(() => { - cleanUp(['images', 'linodes']); - }); - - /* - * - Captures a machine image from a Linode in the targeted region. - * - Confirms that user is redirected to landing page upon image capture. - * - Confirms that user is shown toast notifications related to the image's status. - * - Confirms that the image finishes processing successfully. - */ - testRegions('can capture a Machine Image from a Linode', (region) => { - const imageLabel = randomLabel(); - const imageDescription = randomPhrase(); - - const linodePayload = createLinodeRequestFactory.build({ - label: randomLabel(), - root_pass: randomString(32), - region: region.id, - booted: true, - }); - - cy.defer( - () => createTestLinode(linodePayload, { waitForBoot: true }), - 'creating and booting Linode' - ).then(([linode, disk]: [Linode, Disk]) => { - cy.visitWithLogin('/images/create/disk'); - - // Select Linode that we just created via the API. - cy.findByLabelText('Linode').should('be.visible').click(); - ui.autocompletePopper.findByTitle(linode.label).click(); - - // Select the Linode's disk. - cy.contains('Select a Disk').click(); - cy.focused().type(disk.label); - ui.autocompletePopper.findByTitle(disk.label).click(); - - // Specify a label and description for the captured image, click submit. - cy.findByLabelText('Label').should('be.visible').click(); - cy.focused().type(imageLabel); - - cy.findByLabelText('Description').should('be.visible').click(); - cy.focused().type(imageDescription); - - ui.button - .findByTitle('Create Image') - .should('be.visible') - .should('be.enabled') - .click(); - - // Confirm redirect back to landing page and that new image is listed. - cy.url().should('endWith', '/images'); - ui.toast.assertMessage('Image scheduled for creation.'); - cy.findByText(imageLabel).should('be.visible'); - - // Confirm that image capture finishes successfully. - ui.toast.assertMessage(`Image ${imageLabel} created successfully.`, { - timeout: imageCaptureProcessingTimeout, - }); - - cy.findByText(imageLabel) - .should('be.visible') - .closest('tr') - .within(() => { - cy.findByText('Ready').should('be.visible'); - }); - }); - }); -}); diff --git a/packages/manager/cypress/e2e/region/images/update-delete-machine-image.spec.ts b/packages/manager/cypress/e2e/region/images/update-delete-machine-image.spec.ts deleted file mode 100644 index 625c842c0fc..00000000000 --- a/packages/manager/cypress/e2e/region/images/update-delete-machine-image.spec.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * @file Image update and deletion region tests. - */ - -import type { Image, Region } from '@linode/api-v4'; -import { uploadImage } from '@linode/api-v4'; -import { authenticate } from 'support/api/authentication'; -import { imageUploadProcessingTimeout } from 'support/constants/images'; -import { ui } from 'support/ui'; -import { SimpleBackoffMethod } from 'support/util/backoff'; -import { cleanUp } from 'support/util/cleanup'; -import { pollImageStatus } from 'support/util/polling'; -import { randomLabel, randomPhrase } from 'support/util/random'; -import { testRegions } from 'support/util/regions'; - -/** - * Uploads a machine image and waits for it to become available. - * - * See Linode API v4 documentation for more information. - * - * @link https://techdocs.akamai.com/linode-api/reference/post-upload-image - * - * @param region - Image upload region. - * @param data - Data to upload. - * - * @returns Promise that resolves to uploaded Image object. - */ -const uploadMachineImage = async (region: Region, data: Blob) => { - const uploadResponse = await uploadImage({ - label: randomLabel(), - region: region.id, - }); - - const [endpoint, image] = [uploadResponse.upload_to, uploadResponse.image]; - await fetch(endpoint, { - method: 'PUT', - body: data, - headers: { - 'Content-type': 'application/octet-stream', - }, - }); - - await pollImageStatus( - image.id, - 'available', - new SimpleBackoffMethod(5000, { - initialDelay: 20000, - maxAttempts: 20, - }) - ); - - return image; -}; - -authenticate(); -describe('Delete Machine Images', () => { - before(() => { - cleanUp('images'); - }); - - /* - * - Updates and deletes a Machine Image for the targeted region. - * - Confirms that Image label and description can be updated. - * - Confirms that landing page content changes to reflect updated Image. - * - Confirms that Image can be deleted. - * - Confirms that deleted Image is removed from the landing page. - */ - testRegions('can update and delete a Machine Image', (region) => { - const newLabel = randomLabel(); - const newDescription = randomPhrase(); - - // Upload a machine image using the `test-image.gz` fixture. - // Wait for machine image to become ready, then begin test. - cy.fixture('machine-images/test-image.gz', null).then( - (imageFileContents) => { - cy.defer(() => uploadMachineImage(region, imageFileContents), { - label: 'uploading Machine Image', - timeout: imageUploadProcessingTimeout, - }).then((image: Image) => { - // Navigate to Images landing page, find Image and click its Edit menu item. - cy.visitWithLogin('/images'); - cy.findByText(image.label) - .should('be.visible') - .closest('tr') - .within(() => { - cy.findByText('Ready').should('be.visible'); - ui.actionMenu - .findByTitle(`Action menu for Image ${image.label}`) - .should('be.visible') - .click(); - }); - - ui.actionMenuItem.findByTitle('Edit').should('be.visible').click(); - - // Update Image label and description. - ui.drawer - .findByTitle('Edit Image') - .should('be.visible') - .within(() => { - cy.findByLabelText('Label').should('be.visible').click(); - cy.focused().clear(); - cy.focused().type(newLabel); - - cy.findByLabelText('Description').should('be.visible').click(); - cy.focused().clear(); - cy.focused().type(newDescription); - - ui.button - .findByTitle('Save Changes') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Confirm that new label is shown on landing page, initiate delete. - cy.findByText(newLabel) - .should('be.visible') - .closest('tr') - .within(() => { - ui.actionMenu - .findByTitle(`Action menu for Image ${newLabel}`) - .should('be.visible') - .click(); - }); - - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); - - // Confirm Image delete prompt. - ui.dialog - .findByTitle(`Delete Image ${newLabel}`) - .should('be.visible') - .within(() => { - ui.button - .findByTitle('Delete Image') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Confirm that Image is deleted successfully. - ui.toast.assertMessage('Image has been scheduled for deletion.'); - ui.toast.assertMessage(`Image ${newLabel} deleted successfully.`); - cy.findByText(image.label).should('not.exist'); - cy.findByText(newLabel).should('not.exist'); - }); - } - ); - }); -}); diff --git a/packages/manager/cypress/e2e/region/images/upload-machine-image.spec.ts b/packages/manager/cypress/e2e/region/images/upload-machine-image.spec.ts deleted file mode 100644 index 498eabaf80e..00000000000 --- a/packages/manager/cypress/e2e/region/images/upload-machine-image.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { Region } from '@linode/api-v4'; -import 'cypress-file-upload'; -import { authenticate } from 'support/api/authentication'; -import { imageUploadProcessingTimeout } from 'support/constants/images'; -import { interceptUploadImage } from 'support/intercepts/images'; -import { ui } from 'support/ui'; -import { cleanUp } from 'support/util/cleanup'; -import { randomLabel, randomPhrase } from 'support/util/random'; -import { testRegions } from 'support/util/regions'; - -authenticate(); -describe('Upload Machine Images', () => { - before(() => { - cleanUp('images'); - }); - /* - * - Confirms that users can upload Machine Images to the targeted region. - * - Confirms that user is redirected back to landing page. - * - Confirms that uploaded Image is listed on the landing page. - * - Confirms that Image uploads successfully and landing page reflects its status. - */ - testRegions('can upload a Machine Image', (region: Region) => { - const imageLabel = randomLabel(); - const imageDescription = randomPhrase(); - const imageFile = 'machine-images/test-image.gz'; - - interceptUploadImage().as('uploadImage'); - // Navigate to Image upload page, enter label, select region, and upload Image file. - cy.visitWithLogin('/images/create/upload'); - cy.findByText('Label').should('be.visible').click(); - cy.focused().type(imageLabel); - - cy.findByText('Description').should('be.visible').click(); - cy.focused().type(imageDescription); - - ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionId(region.id).should('be.visible').click(); - - // Pass `null` to `cy.fixture()` to encode file as a Cypress buffer object. - cy.fixture(imageFile, null).then((imageFileContents) => { - ui.fileUpload.find().attachFile({ - fileContent: imageFileContents, - fileName: 'test-image', - mimeType: 'application/x-gzip', - }); - }); - - // Wait for Image upload request and confirm toast notification is shown. - cy.wait('@uploadImage'); - ui.toast.assertMessage( - `Image ${imageLabel} uploaded successfully. It is being processed and will be available shortly.` - ); - - // Confirm redirect back to Images landing, new image is listed, and becomes available. - cy.url().should('endWith', '/images'); - cy.findByText(imageLabel).should('be.visible'); - - ui.toast.assertMessage(`Image ${imageLabel} is now available.`, { - timeout: imageUploadProcessingTimeout, - }); - - cy.findByText(imageLabel) - .should('be.visible') - .closest('tr') - .within(() => { - cy.findByText('Ready').should('be.visible'); - }); - }); -}); diff --git a/packages/manager/cypress/e2e/region/linodes/create-linode.spec.ts b/packages/manager/cypress/e2e/region/linodes/create-linode.spec.ts deleted file mode 100644 index 36cbf115fcc..00000000000 --- a/packages/manager/cypress/e2e/region/linodes/create-linode.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { testRegions } from 'support/util/regions'; -import { ui } from 'support/ui'; -import { randomLabel, randomString } from 'support/util/random'; -import { interceptCreateLinode } from 'support/intercepts/linodes'; - -import type { Region } from '@linode/api-v4'; - -describe('Create Linodes', () => { - /* - * - Navigates to Linode create page. - * - Selects a region, plan (Dedicated 4 GB), and enters label and password. - * - Clicks "Create Linode" and confirms that new Linode boots. - */ - testRegions('can create and boot a Linode', (region: Region) => { - const label = randomLabel(); - - interceptCreateLinode().as('createLinode'); - cy.visitWithLogin('linodes/create'); - - // Select region and plan. - ui.regionSelect.find().click(); - ui.regionSelect.findItemByRegionId(region.id).should('be.visible').click(); - - cy.get('[data-qa-plan-row="Dedicated 4 GB"]') - .closest('tr') - .within(() => { - cy.get('[data-qa-radio]').click(); - }); - - // Enter label and password. - cy.findByLabelText('Linode Label').click(); - cy.focused().clear(); - cy.focused().type(label); - cy.findByLabelText('Root Password').click(); - cy.focused().type(randomString(32)); - - // Submit. - ui.button.findByTitle('Create Linode').click(); - - // Confirm Linode boots. - cy.wait('@createLinode'); - cy.findByText(label).should('be.visible'); - cy.findByText(region.label).should('be.visible'); - cy.findByText('RUNNING', { timeout: 180000 }).should('be.visible'); - }); -}); diff --git a/packages/manager/cypress/e2e/region/linodes/delete-linode.spec.ts b/packages/manager/cypress/e2e/region/linodes/delete-linode.spec.ts deleted file mode 100644 index 92ec17a6ce8..00000000000 --- a/packages/manager/cypress/e2e/region/linodes/delete-linode.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { createLinodeRequestFactory } from '@src/factories'; -import { describeRegions } from 'support/util/regions'; -import { randomLabel, randomString } from 'support/util/random'; -import { Region } from '@linode/api-v4'; -import type { Linode } from '@linode/api-v4'; -import { ui } from 'support/ui'; -import { authenticate } from 'support/api/authentication'; -import { - interceptGetLinodeDetails, - interceptGetLinodes, -} from 'support/intercepts/linodes'; -import { cleanUp } from 'support/util/cleanup'; -import { createTestLinode } from 'support/util/linodes'; - -authenticate(); -describeRegions('Delete Linodes', (region: Region) => { - before(() => { - cleanUp('linodes'); - }); - - /* - * - Navigates to a Linode details page. - * - Deletes the Linode via the "Delete" action menu item. - * - Navigates back to Linode landing page, confirms deleted Linode is not shown. - */ - it('can delete a Linode', () => { - const linodeCreatePayload = createLinodeRequestFactory.build({ - label: randomLabel(), - region: region.id, - root_pass: randomString(32), - booted: false, - }); - - // Create a Linode before navigating to its details page to delete it. - cy.defer( - () => createTestLinode(linodeCreatePayload), - `creating Linode in ${region.label}` - ).then((linode: Linode) => { - interceptGetLinodeDetails(linode.id).as('getLinode'); - cy.visitWithLogin(`/linodes/${linode.id}`); - cy.wait('@getLinode'); - - // Delete Linode via action menu. - ui.actionMenu - .findByTitle(`Action menu for Linode ${linode.label}`) - .should('be.visible') - .click(); - - ui.actionMenuItem.findByTitle('Delete').should('be.visible').click(); - - // Confirm deletion via type-to-confirm dialog. - ui.dialog - .findByTitle(`Delete ${linode.label}?`) - .should('be.visible') - .within(() => { - cy.findByLabelText('Linode Label').should('be.visible').click(); - cy.focused().type(linode.label); - - ui.buttonGroup - .findButtonByTitle('Delete') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Confirm that Linode is no longer listed on landing page. - // Cloud currently does not navigate back to landing page. - // Remove call to `cy.visitWithLogin()` once redirect is restored. - interceptGetLinodes().as('getLinodes'); - cy.visitWithLogin('/linodes'); - cy.wait('@getLinodes'); - cy.findByText(linode.label).should('not.exist'); - }); - }); -}); diff --git a/packages/manager/cypress/e2e/region/linodes/update-linode.spec.ts b/packages/manager/cypress/e2e/region/linodes/update-linode.spec.ts deleted file mode 100644 index 23919669b36..00000000000 --- a/packages/manager/cypress/e2e/region/linodes/update-linode.spec.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { Disk, Linode } from '@linode/api-v4'; -import { getLinodeDisks } from '@linode/api-v4'; -import { createLinodeRequestFactory } from '@src/factories'; -import { authenticate } from 'support/api/authentication'; -import { interceptGetLinodeDetails } from 'support/intercepts/linodes'; -import { ui } from 'support/ui'; -import { cleanUp } from 'support/util/cleanup'; -import { depaginate } from 'support/util/paginate'; -import { randomLabel, randomString } from 'support/util/random'; -import { describeRegions } from 'support/util/regions'; -import { createTestLinode } from 'support/util/linodes'; - -/* - * Returns a Linode create payload for the given region. - */ -const makeLinodePayload = (region: string, booted: boolean) => { - return createLinodeRequestFactory.build({ - label: randomLabel(), - root_pass: randomString(32), - region, - booted, - }); -}; - -authenticate(); -describeRegions('Can update Linodes', (region) => { - before(() => { - cleanUp('linodes'); - }); - - /* - * - Navigates to a Linode details page's "Settings" tab. - * - Enters a new label and clicks "Save". - * - Confirms that label is updated and shown on Linode landing page. - */ - it('can update a Linode label', () => { - cy.defer( - () => createTestLinode(makeLinodePayload(region.id, true)), - 'creating Linode' - ).then((linode: Linode) => { - const newLabel = randomLabel(); - const oldLabel = linode.label; - - // Navigate to Linode details page. - interceptGetLinodeDetails(linode.id).as('getLinode'); - cy.visitWithLogin(`/linodes/${linode.id}`); - cy.wait('@getLinode'); - - // Click on 'Settings' tab. - cy.findByText('Settings').should('be.visible').click(); - - // Type in new label, click "Save". - cy.get('[data-qa-panel="Linode Label"]') - .should('be.visible') - .within(() => { - cy.findByLabelText('Label').should('be.visible').click(); - cy.focused().clear(); - cy.focused().type(newLabel); - - ui.button - .findByTitle('Save') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - // Confirm that label has been updated. - ui.toast.assertMessage( - `Successfully updated Linode label to ${newLabel}` - ); - ui.entityHeader.find().within(() => { - cy.findByText(newLabel).should('be.visible'); - cy.findByText('linodes').should('be.visible').click(); - }); - - cy.url().should('endWith', '/linodes'); - cy.findByText(oldLabel).should('not.exist'); - cy.findByText(newLabel).should('be.visible'); - }); - }); - - /* - * - Navigates to a Linode details page's "Settings" tab. - * - Enters a new password and clicks "Save". - * - Confirms that successful toast notification appears. - */ - it('can update a Linode root password', () => { - const newPassword = randomString(32); - - const createLinodeAndGetDisk = async (): Promise<[Linode, Disk]> => { - const linode = await createTestLinode( - makeLinodePayload(region.id, false) - ); - const disks = await depaginate((page) => - getLinodeDisks(linode.id, { page }) - ); - - // Throw if Linode has no disks. Shouldn't happen in practice. - if (!disks[0]) { - throw new Error('Created Linode does not have any disks'); - } - return [linode, disks[0]]; - }; - - cy.defer(() => createLinodeAndGetDisk(), 'creating Linode').then( - ([linode, disk]: [Linode, Disk]) => { - // Navigate to Linode details page. - interceptGetLinodeDetails(linode.id).as('getLinode'); - cy.visitWithLogin(`/linodes/${linode.id}`); - cy.wait('@getLinode'); - - // Wait for Linode to finish provisioning. - cy.findByText('PROVISIONING').should('not.exist'); - cy.findByText('OFFLINE').should('be.visible'); - - // Click on 'Settings' tab. - cy.findByText('Settings').should('be.visible').click(); - - cy.get('[data-qa-panel="Reset Root Password"]') - .should('be.visible') - .within(() => { - cy.findByText('Disk').should('be.visible').clear(); - cy.focused().type(disk.label); - - ui.autocompletePopper - .findByTitle(disk.label) - .should('be.visible') - .click(); - - cy.findByLabelText('New Root Password') - .should('be.visible') - .clear(); - cy.focused().clear(); - cy.focused().type(newPassword); - - ui.button - .findByTitle('Save') - .should('be.visible') - .should('be.enabled') - .click(); - }); - - ui.toast.assertMessage('Sucessfully changed password'); - // TODO Investigate whether e2e solution to test password can be done securely. - } - ); - }); -}); diff --git a/packages/manager/cypress/support/api/vlans.ts b/packages/manager/cypress/support/api/vlans.ts new file mode 100644 index 00000000000..9faeaadb412 --- /dev/null +++ b/packages/manager/cypress/support/api/vlans.ts @@ -0,0 +1,28 @@ +import { VLAN, getVlans } from '@linode/api-v4'; +import { pageSize } from 'support/constants/api'; +import { depaginate } from 'support/util/paginate'; + +import { isTestLabel } from './common'; +import { randomLabel } from 'support/util/random'; + +/** + * Returns a VLAN label to use for a test resource, creating it if one does not already exist. + * + * @returns Promise that resolves to existing or new VLAN label. + */ +export const findOrCreateDependencyVlan = async (linodeRegion: string) => { + const vlans = await depaginate((page: number) => + getVlans({ page, page_size: pageSize }) + ); + + const suitableVlan = vlans.find(({ label, region }: VLAN) => { + return isTestLabel(label) && region === linodeRegion; + }); + + if (suitableVlan) { + return suitableVlan.label; + } + + // No suitable VLANs exist, so we'll return random label and create a new one later. + return randomLabel(); +}; diff --git a/packages/manager/cypress/support/component/setup.tsx b/packages/manager/cypress/support/component/setup.tsx index 6f4f2e0b5a4..1fc6e7ece96 100644 --- a/packages/manager/cypress/support/component/setup.tsx +++ b/packages/manager/cypress/support/component/setup.tsx @@ -13,8 +13,15 @@ // https://on.cypress.io/configuration // *********************************************************** -import { queryClientFactory } from '@src/queries/base'; +import { queryClientFactory } from '@linode/queries'; import { QueryClientProvider } from '@tanstack/react-query'; +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '@tanstack/react-router'; import '@testing-library/cypress/add-commands'; import 'cypress-axe'; import { mount } from 'cypress/react'; @@ -28,6 +35,8 @@ import { LinodeThemeWrapper } from 'src/LinodeThemeWrapper'; import { storeFactory } from 'src/store'; import type { ThemeName } from '@linode/ui'; +import type { AnyRouter } from '@tanstack/react-router'; +import type { Flags } from 'src/featureFlags'; /** * Mounts a component with a Cloud Manager theme applied. @@ -38,10 +47,23 @@ import type { ThemeName } from '@linode/ui'; export const mountWithTheme = ( jsx: React.ReactNode, theme: ThemeName = 'light', - flags: any = {} + flags: Partial = {}, + useTanstackRouter: boolean = false ) => { const queryClient = queryClientFactory(); const store = storeFactory(); + const rootRoute = createRootRoute({}); + const indexRoute = createRoute({ + component: () => jsx, + getParentRoute: () => rootRoute, + path: '/', + }); + const router: AnyRouter = createRouter({ + history: createMemoryHistory({ + initialEntries: ['/'], + }), + routeTree: rootRoute.addChildren([indexRoute]), + }); return mount( @@ -54,7 +76,13 @@ export const mountWithTheme = ( options={{ bootstrap: flags }} > - {jsx} + {useTanstackRouter ? ( + + + + ) : ( + {jsx} + )} diff --git a/packages/manager/cypress/support/constants/account.ts b/packages/manager/cypress/support/constants/account.ts index c8030ac4cbc..58868cc7363 100644 --- a/packages/manager/cypress/support/constants/account.ts +++ b/packages/manager/cypress/support/constants/account.ts @@ -35,3 +35,18 @@ export const loginHelperText = * Empty state message that appears when there is no item in the login history table. */ export const loginEmptyStateMessageText = 'No account logins'; + +/** + * Warning message that appears when users is trying to enable Linode Managed. + */ +export const linodeEnabledMessageText = (count: number): string => { + return `Linode Managed costs an additional $100 per month per Linode. You currently have ${count} Linodes, so Managed will increase your projected monthly bill by $${ + 100 * count + }.`; +}; + +/** + * Message that tells the Linode Managed is enabled. + */ +export const linodeManagedStateMessageText = + 'Managed is already enabled on your account'; diff --git a/packages/manager/cypress/support/constants/alert.ts b/packages/manager/cypress/support/constants/alert.ts index 56cb2210a32..45190a28e47 100644 --- a/packages/manager/cypress/support/constants/alert.ts +++ b/packages/manager/cypress/support/constants/alert.ts @@ -1,5 +1,6 @@ import type { AlertSeverityType, + AlertStatusType, DimensionFilterOperatorType, MetricAggregationType, MetricOperatorType, @@ -36,3 +37,10 @@ export const aggregationTypeMap: Record = { min: 'Minimum', sum: 'Sum', }; + +export const statusMap: Record = { + disabled: 'Disabled', + enabled: 'Enabled', + failed: 'Failed', + 'in progress': 'In Progress', +}; diff --git a/packages/manager/cypress/support/constants/cloudpulse.ts b/packages/manager/cypress/support/constants/cloudpulse.ts index 0b3a1e2f0cd..ab0fd44ceb0 100644 --- a/packages/manager/cypress/support/constants/cloudpulse.ts +++ b/packages/manager/cypress/support/constants/cloudpulse.ts @@ -7,3 +7,30 @@ export const cloudPulseServiceMap: Record = { dbaas: 'Databases', linode: 'Linode', }; +/** + * Descriptions used in the Create/Edit Alert form to guide users + * in configuring alert conditions effectively. + */ +export const METRIC_DESCRIPTION_DATA_FIELD = + 'Represents the metric you want to receive alerts for. Choose the one that helps you evaluate performance of your service in the most efficient way. For multiple metrics we use the AND method by default.'; + +/** + * Defines a severity level associated with the alert + * to help prioritize and manage alerts in the Recent Activity tab. + */ +export const SEVERITY_LEVEL_DESCRIPTION = + 'Define a severity level associated with the alert to help you prioritize and manage alerts in the Recent activity tab.'; + +/** + * Defines the timeframe for collecting data in polling intervals + * to understand service performance. + * Determines the data lookback period where thresholds are applied. + */ +export const EVALUATION_PERIOD_DESCRIPTION = + 'Defines the timeframe for collecting data in polling intervals to understand the service performance. Choose the data lookback period where the thresholds are applied to gather the information impactful for your business.'; + +/** + * Specifies how often the alert condition should be evaluated. + */ +export const POLLING_INTERVAL_DESCRIPTION = + 'Choose how often you intend to evaluate the alert condition.'; diff --git a/packages/manager/cypress/support/cypress-exports.ts b/packages/manager/cypress/support/cypress-exports.ts index 66a609c681d..ade1c5cf39b 100644 --- a/packages/manager/cypress/support/cypress-exports.ts +++ b/packages/manager/cypress/support/cypress-exports.ts @@ -3,4 +3,4 @@ // // Cypress issue: https://github.com/cypress-io/cypress/issues/27973 // Extra Context: https://github.com/linode/manager/pull/11611#discussion_r1941711748 -export * from '../../../../node_modules/cypress/types/net-stubbing'; +export * from '../../node_modules/cypress/types/net-stubbing'; diff --git a/packages/manager/cypress/support/intercepts/account.ts b/packages/manager/cypress/support/intercepts/account.ts index fb193793309..f27c823340f 100644 --- a/packages/manager/cypress/support/intercepts/account.ts +++ b/packages/manager/cypress/support/intercepts/account.ts @@ -305,6 +305,25 @@ export const mockGetEntityTransfers = ( }); }; +/** + * Intercepts GET request to fetch service transfers and mocks an error response. + * + * @param errorMessage - API error message with which to mock response. + * @param statusCode - HTTP status code with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockGetEntityTransfersError = ( + errorMessage: string = 'An unknown error has occurred', + statusCode: number = 500 +) => { + return cy.intercept( + 'GET', + apiMatcher('account/entity-transfers*'), + makeErrorResponse(errorMessage, statusCode) + ); +}; + /** * Intercepts GET request to receive entity transfer and mocks response. * @@ -722,3 +741,35 @@ export const mockGetMaintenance = ( export const interceptGetAccountAvailability = (): Cypress.Chainable => { return cy.intercept('GET', apiMatcher('account/availability*')); }; + +/** + * Mocks POST request to enable the Linode Managed. + * + * @returns Cypress chainable. + */ +export const mockEnableLinodeManaged = (): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher('account/settings/managed-enable'), + makeResponse() + ); +}; + +/** + * Mocks POST request to to enable the Linode Managed and mocks an error response. + * + * @param errorMessage - API error message with which to mock response. + * @param statusCode - HTTP status code with which to mock response. + * + * @returns Cypress chainable. + */ +export const mockEnableLinodeManagedError = ( + errorMessage: string = 'An unknown error has occurred', + statusCode: number = 400 +) => { + return cy.intercept( + 'POST', + apiMatcher('account/settings/managed-enable'), + makeErrorResponse(errorMessage, statusCode) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/cloudpulse.ts b/packages/manager/cypress/support/intercepts/cloudpulse.ts index 8bcbbe22d65..b2c86d73270 100644 --- a/packages/manager/cypress/support/intercepts/cloudpulse.ts +++ b/packages/manager/cypress/support/intercepts/cloudpulse.ts @@ -392,3 +392,128 @@ export const mockUpdateAlertDefinitions = ( makeResponse(alert) ); }; +/** + * Mocks the API response for retrieving alert definitions in the monitoring service. + * This function intercepts a `GET` request to fetch a list of alert definitions for a specific + * service type and returns a mock response, simulating the behavior of the real API. + * + * The mock response allows the test to simulate the scenario where the system is retrieving + * the alert definitions without actually calling the backend API. + * + * @param {string} serviceType - The type of service (e.g., "dbaas", "web") for which the alert + * definitions are being retrieved. This value is part of the URL in the request. + * @param {Alert[]} alert - An array of `Alert` objects that will be returned as the mock response. + * These objects represent the alert definitions being fetched. + * + * @returns {Cypress.Chainable} - A Cypress chainable object that represents the intercepted + * `GET` request and the mock response, allowing subsequent + * Cypress commands to be chained. + */ + +export const mockGetAlertDefinition = ( + serviceType: string, + alert: Alert[] +): Cypress.Chainable => { + return cy.intercept( + 'GET', + apiMatcher(`/monitor/services/${serviceType}/alert-definitions`), + paginateResponse(alert) + ); +}; + +/** + * Mocks the API response for adding an entity to an alert definition in the monitoring service. + * This function intercepts a 'POST' request to associate a specific entity with an alert definition + * and returns a mock response, simulating the behavior of the real API. + * + * The mock response simulates the scenario where the system is successfully associating the + * entity with the alert definition without actually calling the backend API. + * + * @param {string} serviceType - The type of service (e.g., "dbaas", "Linocde") where the alert + * definition is being added. This value is part of the URL in the request. + * @param {string} entityId - The unique identifier of the entity being associated with the alert + * definition. This ID is part of the URL in the request. + * @param {Object} data - The data object containing the `alert-definition-id` being added to the entity. + * This object contains the alert definition that will be associated with the entity. + * + * @returns {Cypress.Chainable} - A Cypress chainable object that represents the intercepted + * 'POST' request and the mock response, allowing subsequent + * Cypress commands to be chained. + */ + +export const mockAddEntityToAlert = ( + serviceType: string, + entityId: string, + data: { 'alert-definition-id': number } +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher( + `/monitor/service/${serviceType}/entity/${entityId}/alert-definition` + ), + paginateResponse(data) + ); +}; + +/** + * Mocks the API response for adding an entity to an alert definition in the monitoring service. + * This function intercepts a 'DELETE' request to associate a specific entity with an alert definition + * and returns a mock response, simulating the behavior of the real API. + * + * The mock response simulates the scenario where the system is successfully associating the + * entity with the alert definition without actually calling the backend API. + * + * @param {string} serviceType - The type of service (e.g., "dbaas", "Linode") where the alert + * definition is being added. This value is part of the URL in the request. + * @param {string} entityId - The unique identifier of the entity being associated with the alert + * definition. This ID is part of the URL in the request. + * + * @param {number} alertId - The unique identifier of the alert definition from which the entity + * is being removed. This ID is part of the URL in the request. + * + * + * @returns {Cypress.Chainable} - A Cypress chainable object that represents the intercepted + * 'DELETE' request and the mock response, allowing subsequent + * Cypress commands to be chained. + */ + +export const mockDeleteEntityFromAlert = ( + serviceType: string, + entityId: string, + id: number +): Cypress.Chainable => { + return cy.intercept( + 'DELETE', + apiMatcher( + `/monitor/service/${serviceType}/entity/${entityId}/alert-definition/${id}` + ), + { + statusCode: 200, + } + ); +}; + +/** + * Intercepts and mocks – Indicates enabling/disabling alerts with error handling. + * + * @param {string} serviceType - The type of service for which the alert definition belongs. + * @param {number} id - The unique identifier of the alert definition. + * @param {string} errorMessage - The error message to be returned in the response. + * @param {number} [status=500] - The HTTP status code for the error response (default: 500). + * @returns {Cypress.Chainable} - A Cypress intercept that simulates a failed API request. + * + * This function is used in Cypress tests to simulate a failed API call when updating + * alert definitions. It intercepts `PUT` requests to the specified API endpoint and + * returns a mock error response. + */ +export const mockUpdateAlertDefinitionsError = ( + serviceType: string, + id: number, + errorMessage: string +): Cypress.Chainable => { + return cy.intercept( + 'PUT', + apiMatcher(`/monitor/services/${serviceType}/alert-definitions/${id}`), + makeErrorResponse(errorMessage, 500) + ); +}; diff --git a/packages/manager/cypress/support/intercepts/configs.ts b/packages/manager/cypress/support/intercepts/configs.ts index ca54fe8b6d5..3a6396050e9 100644 --- a/packages/manager/cypress/support/intercepts/configs.ts +++ b/packages/manager/cypress/support/intercepts/configs.ts @@ -170,6 +170,6 @@ export const mockCreateLinodeConfigInterfaces = ( return cy.intercept( 'POST', apiMatcher(`linode/instances/${linodeId}/configs/${config.id}/interfaces`), - config.interfaces + config.interfaces ?? undefined ); }; diff --git a/packages/manager/cypress/support/intercepts/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index d46bcc8dd1b..b9433352cab 100644 --- a/packages/manager/cypress/support/intercepts/linodes.ts +++ b/packages/manager/cypress/support/intercepts/linodes.ts @@ -117,7 +117,7 @@ export const interceptGetLinodes = (): Cypress.Chainable => { export const mockGetLinodes = (linodes: Linode[]): Cypress.Chainable => { return cy.intercept( 'GET', - apiMatcher('linode/instances/*'), + apiMatcher('linode/instances/**'), paginateResponse(linodes) ); }; @@ -189,6 +189,25 @@ export const interceptRebuildLinode = ( ); }; +/** + * Intercepts POST request to rebuild a Linode and mocks the response. + * + * @param linodeId - ID of Linode for intercepted request. + * @param linode - Linode for the mocked response + * + * @returns Cypress chainable. + */ +export const mockRebuildLinode = ( + linodeId: number, + linode: Linode +): Cypress.Chainable => { + return cy.intercept( + 'POST', + apiMatcher(`linode/instances/${linodeId}/rebuild`), + makeResponse(linode) + ); +}; + /** * Intercepts POST request to rebuild a Linode and mocks an error response. * diff --git a/packages/manager/cypress/support/intercepts/stackscripts.ts b/packages/manager/cypress/support/intercepts/stackscripts.ts index dbf0e2a5582..0886ec608ce 100644 --- a/packages/manager/cypress/support/intercepts/stackscripts.ts +++ b/packages/manager/cypress/support/intercepts/stackscripts.ts @@ -17,6 +17,17 @@ export const interceptGetStackScripts = (): Cypress.Chainable => { return cy.intercept('GET', apiMatcher('linode/stackscripts*')); }; +/** + * Intercepts GET request to a StackScript. + * + * @returns Cypress chainable. + */ +export const interceptGetStackScript = ( + id: number +): Cypress.Chainable => { + return cy.intercept('GET', apiMatcher(`linode/stackscripts/${id}`)); +}; + /** * Intercepts GET request to mock StackScript data. * diff --git a/packages/manager/cypress/support/plugins/configure-test-suite.ts b/packages/manager/cypress/support/plugins/configure-test-suite.ts index 5db9ed6b4b6..0e431ee07a9 100644 --- a/packages/manager/cypress/support/plugins/configure-test-suite.ts +++ b/packages/manager/cypress/support/plugins/configure-test-suite.ts @@ -20,9 +20,6 @@ export const configureTestSuite: CypressPlugin = (_on, config) => { case 'synthetic': return 'synthetic'; - case 'region': - return 'region'; - case 'core': default: if (!!config.env[envVarName] && config.env[envVarName] !== 'core') { diff --git a/packages/manager/cypress/support/plugins/junit-report.ts b/packages/manager/cypress/support/plugins/junit-report.ts index a60ded4521b..33117a64823 100644 --- a/packages/manager/cypress/support/plugins/junit-report.ts +++ b/packages/manager/cypress/support/plugins/junit-report.ts @@ -33,7 +33,7 @@ export const enableJunitReport = ( // Cypress doesn't know to look for modules in the root `node_modules` // directory, so we have to pass a relative path. // See also: https://github.com/cypress-io/cypress/issues/6406 - config.reporter = '../../node_modules/mocha-junit-reporter'; + config.reporter = 'node_modules/mocha-junit-reporter'; // See also: https://www.npmjs.com/package/mocha-junit-reporter#full-configuration-options config.reporterOptions = { diff --git a/packages/manager/cypress/support/plugins/split-run.ts b/packages/manager/cypress/support/plugins/split-run.ts index eccb6c0e153..de4919fd6cb 100644 --- a/packages/manager/cypress/support/plugins/split-run.ts +++ b/packages/manager/cypress/support/plugins/split-run.ts @@ -86,7 +86,7 @@ export const splitCypressRun: CypressPlugin = (_on, config) => { 'You can optimize your CI run performance by generating a valid weights file' ); console.info( - `Example: CY_TEST_GENWEIGHTS='${splitRunWeightsPath}' yarn cy:run` + `Example: CY_TEST_GENWEIGHTS='${splitRunWeightsPath}' pnpm cy:run` ); })(); } diff --git a/packages/manager/cypress/support/util/accessibility.ts b/packages/manager/cypress/support/util/accessibility.ts index 1dc2e8193a6..27e3e6a238d 100644 --- a/packages/manager/cypress/support/util/accessibility.ts +++ b/packages/manager/cypress/support/util/accessibility.ts @@ -13,13 +13,10 @@ * @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.injectAxe(); cy.checkA11y(componentContext, { runOnly: { type: 'tag', diff --git a/packages/manager/cypress/support/util/components.ts b/packages/manager/cypress/support/util/components.ts index 8940550bc40..7eca479968c 100644 --- a/packages/manager/cypress/support/util/components.ts +++ b/packages/manager/cypress/support/util/components.ts @@ -4,6 +4,7 @@ import type { ThemeName } from '@linode/ui'; import type { MountReturn } from 'cypress/react'; +import type { Flags } from 'src/featureFlags'; /** * Array of themes for which to test components. @@ -46,10 +47,13 @@ export type MountCommand = ( */ export const componentTests = ( componentName: string, - callback: (mountCommand: MountCommand) => void + callback: (mountCommand: MountCommand) => void, + options: { + useTanstackRouter?: boolean; + } = {} ) => { - const mountCommand = (jsx: React.ReactNode, flags?: any) => - cy.mountWithTheme(jsx, defaultTheme, flags); + const mountCommand = (jsx: React.ReactNode, flags?: Flags) => + cy.mountWithTheme(jsx, defaultTheme, flags, options.useTanstackRouter); describe(`${componentName} component tests`, () => { callback(mountCommand); }); diff --git a/packages/manager/cypress/support/util/linodes.ts b/packages/manager/cypress/support/util/linodes.ts index da303ff0bb0..d6b07f2c131 100644 --- a/packages/manager/cypress/support/util/linodes.ts +++ b/packages/manager/cypress/support/util/linodes.ts @@ -1,6 +1,7 @@ import { createLinode, getLinodeConfigs } from '@linode/api-v4'; import { createLinodeRequestFactory } from '@src/factories'; import { findOrCreateDependencyFirewall } from 'support/api/firewalls'; +import { findOrCreateDependencyVlan } from 'support/api/vlans'; import { pageSize } from 'support/constants/api'; import { SimpleBackoffMethod } from 'support/util/backoff'; import { pollLinodeDiskStatuses, pollLinodeStatus } from 'support/util/polling'; @@ -84,6 +85,11 @@ export const createTestLinode = async ( ...(options || {}), }; + let regionId = createRequestPayload?.region; + if (!regionId) { + regionId = chooseRegion().id; + } + const securityMethodPayload: Partial = await (async () => { switch (resolvedOptions.securityMethod) { case 'firewall': @@ -94,8 +100,11 @@ export const createTestLinode = async ( }; case 'vlan_no_internet': + const vlanConfig = linodeVlanNoInternetConfig; + const vlanLabel = await findOrCreateDependencyVlan(regionId); + vlanConfig[0].label = vlanLabel; return { - interfaces: linodeVlanNoInternetConfig, + interfaces: vlanConfig, }; case 'powered_off': @@ -110,7 +119,7 @@ export const createTestLinode = async ( booted: false, image: 'linode/ubuntu24.04', label: randomLabel(), - region: chooseRegion().id, + region: regionId, }), ...(createRequestPayload || {}), ...securityMethodPayload, @@ -138,6 +147,7 @@ export const createTestLinode = async ( ); } + // eslint-disable-next-line const linode = await createLinode(resolvedCreatePayload); // Wait for disks to become available if `waitForDisks` option is set. diff --git a/packages/manager/cypress/support/util/lke.ts b/packages/manager/cypress/support/util/lke.ts index e1e65848532..63005b76c96 100644 --- a/packages/manager/cypress/support/util/lke.ts +++ b/packages/manager/cypress/support/util/lke.ts @@ -1,4 +1,4 @@ -import { sortByVersion } from 'src/utilities/sort-by'; +import { sortByVersion } from '@linode/utilities'; /** * Returns the string of the highest semantic version. diff --git a/packages/manager/cypress/support/util/regions.ts b/packages/manager/cypress/support/util/regions.ts index 888fd547c46..1df135b8ffb 100644 --- a/packages/manager/cypress/support/util/regions.ts +++ b/packages/manager/cypress/support/util/regions.ts @@ -1,6 +1,7 @@ +import { getNewRegionLabel } from '@linode/utilities'; import { randomItem } from 'support/util/random'; + import { buildArray, shuffleArray } from './arrays'; -import { getNewRegionLabel } from 'src/components/RegionSelect/RegionSelect.utils'; import type { Capabilities, Region } from '@linode/api-v4'; @@ -31,7 +32,7 @@ export interface ExtendedRegion extends Region { * @returns `true` if `region` is an `ExtendedRegion` instance, `false` otherwise. */ export const isExtendedRegion = ( - region: Region | ExtendedRegion + region: ExtendedRegion | Region ): region is ExtendedRegion => { if ('apiLabel' in region) { return true; @@ -50,13 +51,13 @@ export const isExtendedRegion = ( * @returns `ExtendedRegion` object for `region`. */ export const extendRegion = ( - region: Region | ExtendedRegion + region: ExtendedRegion | Region ): ExtendedRegion => { if (!isExtendedRegion(region)) { return { ...region, - label: getNewRegionLabel(region), apiLabel: region.label, + label: getNewRegionLabel(region), }; } return region; @@ -73,14 +74,14 @@ export const getRegionFromExtendedRegion = ( extendedRegion: ExtendedRegion ): Region => { return { + capabilities: extendedRegion.capabilities, + country: extendedRegion.country, id: extendedRegion.id, label: extendedRegion.apiLabel, - country: extendedRegion.country, - capabilities: extendedRegion.capabilities, placement_group_limits: extendedRegion.placement_group_limits, - status: extendedRegion.status, resolvers: extendedRegion.resolvers, site_type: extendedRegion.site_type, + status: extendedRegion.status, }; }; diff --git a/packages/manager/package.json b/packages/manager/package.json index 2f7f2218c2b..f4cbb993877 100644 --- a/packages/manager/package.json +++ b/packages/manager/package.json @@ -2,7 +2,7 @@ "name": "linode-manager", "author": "Linode", "description": "The Linode Manager website", - "version": "1.137.2", + "version": "1.138.0", "private": true, "type": "module", "bugs": { @@ -14,6 +14,7 @@ "url": "https://github.com/Linode/manager.git" }, "dependencies": { + "@braintree/sanitize-url": "^7.1.0", "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -22,25 +23,29 @@ "@fontsource/fira-code": "^5.1.1", "@fontsource/nunito-sans": "^5.1.1", "@hookform/resolvers": "3.9.1", - "@linode/api-v4": "*", - "@linode/design-language-system": "^3.0.0", - "@linode/search": "*", - "@linode/ui": "*", - "@linode/validation": "*", + "@linode/api-v4": "workspace:*", + "@linode/design-language-system": "^4.0.0", + "@linode/validation": "workspace:*", + "@linode/queries": "workspace:*", + "@linode/search": "workspace:*", + "@linode/ui": "workspace:*", + "@linode/utilities": "workspace:*", "@lukemorales/query-key-factory": "^1.3.4", - "@mui/icons-material": "^5.14.7", - "@mui/material": "^5.14.7", - "@mui/utils": "^5.14.7", - "@mui/x-date-pickers": "^7.12.0", + "@mui/icons-material": "^6.4.5", + "@mui/material": "^6.4.5", + "@mui/utils": "^6.4.3", + "@mui/x-date-pickers": "^7.27.0", "@paypal/react-paypal-js": "^7.8.3", "@reach/tabs": "^0.10.5", "@sentry/react": "^7.119.1", + "@shikijs/langs": "^3.1.0", + "@shikijs/themes": "^3.1.0", "@tanstack/react-query": "5.51.24", "@tanstack/react-query-devtools": "5.51.24", - "@tanstack/react-router": "^1.58.3", + "@tanstack/react-router": "^1.111.11", "@xterm/xterm": "^5.5.0", "algoliasearch": "^4.14.3", - "axios": "~1.7.4", + "axios": "~1.8.3", "braintree-web": "^3.92.2", "chart.js": "~2.9.4", "copy-to-clipboard": "^3.0.8", @@ -53,8 +58,8 @@ "immer": "^9.0.6", "ipaddr.js": "^1.9.1", "js-sha256": "^0.11.0", - "jspdf": "^2.5.2", - "jspdf-autotable": "^3.5.14", + "jspdf": "^3.0.1", + "jspdf-autotable": "^5.0.2", "launchdarkly-react-client-sdk": "3.0.10", "libphonenumber-js": "^1.10.6", "logic-query-parser": "^0.0.5", @@ -73,14 +78,14 @@ "react-redux": "~7.1.3", "react-router-dom": "~5.3.4", "react-router-hash-link": "^2.3.1", - "react-vnc": "^2.0.2", + "react-vnc": "^3.0.7", "react-waypoint": "^10.3.0", "recharts": "^2.14.1", "recompose": "^0.30.0", "redux": "^4.0.4", "redux-thunk": "^2.3.0", "search-string": "^3.1.0", - "shiki": "^2.3.2", + "shiki": "^3.1.0", "throttle-debounce": "^2.0.0", "tss-react": "^4.8.2", "typescript-fsa": "^3.0.0", @@ -92,13 +97,11 @@ "start": "concurrently --raw \"NODE_OPTIONS='--max-old-space-size=4096' vite\" \"NODE_OPTIONS='--max-old-space-size=4096' tsc --watch --preserveWatchOutput\"", "start:expose": "concurrently --raw \"vite --host\" \"tsc --watch --preserveWatchOutput\"", "start:ci": "vite preview --port 3000 --host", - "lint": "yarn run eslint . --ext .js,.ts,.tsx --quiet", + "lint": "eslint . --ext .js,.ts,.tsx --quiet", "build": "vite build", "build:analyze": "bunx vite-bundle-visualizer", - "precommit": "lint-staged && yarn typecheck", "test": "vitest run", "test:ui": "vitest --ui", - "test:debug": "node --inspect-brk scripts/test.js --runInBand", "storybook": "NODE_OPTIONS='--max-old-space-size=4096' storybook dev -p 6006", "storybook-static": "storybook build -c .storybook -o .out", "build-storybook": "storybook build", @@ -116,25 +119,28 @@ "*.{ts,tsx,js}": [ "prettier --write", "eslint --ext .js,.ts,.tsx --quiet" + ], + "*.{ts,tsx}": [ + "sh -c 'pnpm typecheck'" ] }, "devDependencies": { "@4tw/cypress-drag-drop": "^2.3.0", - "@linode/eslint-plugin-cloud-manager": "^0.0.5", - "@storybook/addon-a11y": "^8.4.7", - "@storybook/addon-actions": "^8.4.7", - "@storybook/addon-controls": "^8.4.7", - "@storybook/addon-docs": "^8.4.7", - "@storybook/addon-mdx-gfm": "^8.4.7", - "@storybook/addon-measure": "^8.4.7", - "@storybook/addon-storysource": "^8.4.7", - "@storybook/addon-viewport": "^8.4.7", - "@storybook/blocks": "^8.4.7", - "@storybook/manager-api": "^8.4.7", - "@storybook/preview-api": "^8.4.7", - "@storybook/react": "^8.4.7", - "@storybook/react-vite": "^8.4.7", - "@storybook/theming": "^8.4.7", + "@linode/eslint-plugin-cloud-manager": "^0.0.7", + "@storybook/addon-a11y": "^8.6.7", + "@storybook/addon-actions": "^8.6.7", + "@storybook/addon-controls": "^8.6.7", + "@storybook/addon-docs": "^8.6.7", + "@storybook/addon-mdx-gfm": "^8.6.7", + "@storybook/addon-measure": "^8.6.7", + "@storybook/addon-storysource": "^8.6.7", + "@storybook/addon-viewport": "^8.6.7", + "@storybook/blocks": "^8.6.7", + "@storybook/manager-api": "^8.6.7", + "@storybook/preview-api": "^8.6.7", + "@storybook/react": "^8.6.7", + "@storybook/react-vite": "^8.6.7", + "@storybook/theming": "^8.6.7", "@swc/core": "^1.10.9", "@testing-library/cypress": "^10.0.3", "@testing-library/dom": "^10.1.0", @@ -145,8 +151,8 @@ "@types/chai-string": "^1.4.5", "@types/chart.js": "^2.9.21", "@types/css-mediaquery": "^0.1.1", - "@types/dompurify": "^3.0.5", "@types/he": "^1.1.0", + "@types/history": "4", "@types/jspdf": "^1.3.3", "@types/luxon": "3.4.2", "@types/markdown-it": "^14.1.2", @@ -169,9 +175,10 @@ "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@vitejs/plugin-react-swc": "^3.7.2", - "@vitest/coverage-v8": "^3.0.3", - "@vitest/ui": "^3.0.3", + "@vitest/coverage-v8": "^3.0.7", + "axe-core": "^4.10.2", "chai-string": "^1.5.0", + "concurrently": "^9.1.0", "css-mediaquery": "^0.1.2", "cypress": "14.0.1", "cypress-axe": "^1.6.0", @@ -188,23 +195,23 @@ "eslint-plugin-ramda": "^2.5.1", "eslint-plugin-react": "^7.19.0", "eslint-plugin-react-hooks": "^3.0.0", - "eslint-plugin-react-refresh": "^0.4.13", + "eslint-plugin-react-refresh": "0.4.13", "eslint-plugin-scanjs-rules": "^0.2.1", "eslint-plugin-sonarjs": "^0.5.0", "eslint-plugin-testing-library": "^3.1.2", "eslint-plugin-xss": "^0.1.10", "factory.ts": "^0.5.1", "glob": "^10.3.1", + "history": "4", "jsdom": "^24.1.1", - "lint-staged": "^15.2.9", "mocha-junit-reporter": "^2.2.1", "msw": "^2.2.3", "pdfreader": "^3.0.7", "prettier": "~2.2.1", "redux-mock-store": "^1.5.3", - "storybook": "^8.4.7", + "storybook": "^8.6.7", "storybook-dark-mode": "4.0.1", - "vite": "^6.0.11", + "vite": "^6.2.2", "vite-plugin-svgr": "^3.2.0" }, "browserslist": [ diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index 5b8bc4b2009..d078cf5e730 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -1,6 +1,6 @@ import { Box } from '@linode/ui'; import { useMediaQuery } from '@mui/material'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { useQueryClient } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; import * as React from 'react'; @@ -27,7 +27,9 @@ import { TopMenu } from 'src/features/TopMenu/TopMenu'; import { useMutatePreferences, usePreferences, -} from 'src/queries/profile/preferences'; + useAccountSettings, + useProfile, +} from '@linode/queries'; import { useIsPageScrollable } from './components/PrimaryNav/utils'; import { ENABLE_MAINTENANCE_MODE } from './constants'; @@ -39,8 +41,6 @@ import { useIsDatabasesEnabled } from './features/Databases/utilities'; import { useIsIAMEnabled } from './features/IAM/Shared/utilities'; import { TOPMENU_HEIGHT } from './features/TopMenu/constants'; import { useGlobalErrors } from './hooks/useGlobalErrors'; -import { useAccountSettings } from './queries/account/settings'; -import { useProfile } from './queries/profile/profile'; import { migrationRouter } from './routes'; import type { Theme } from '@mui/material/styles'; @@ -132,9 +132,6 @@ const Profile = React.lazy(() => const NodeBalancers = React.lazy( () => import('src/features/NodeBalancers/NodeBalancers') ); -const StackScripts = React.lazy( - () => import('src/features/StackScripts/StackScripts') -); const SupportTickets = React.lazy( () => import('src/features/Support/SupportTickets') ); @@ -162,14 +159,23 @@ const EventsLanding = React.lazy(() => const AccountActivationLanding = React.lazy( () => import('src/components/AccountActivation/AccountActivationLanding') ); -const Firewalls = React.lazy(() => import('src/features/Firewalls')); const Databases = React.lazy(() => import('src/features/Databases')); const VPC = React.lazy(() => import('src/features/VPCs')); -const CloudPulse = React.lazy(() => - import('src/features/CloudPulse/CloudPulseLanding').then((module) => ({ - default: module.CloudPulseLanding, - })) +const CloudPulseMetrics = React.lazy(() => + import('src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding').then( + (module) => ({ + default: module.CloudPulseDashboardLanding, + }) + ) +); + +const CloudPulseAlerts = React.lazy(() => + import('src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding').then( + (module) => ({ + default: module.AlertsLanding, + }) + ) ); const IAM = React.lazy(() => @@ -367,10 +373,6 @@ export const MainContent = () => { path="/nodebalancers" /> - { - {isDatabasesEnabled && ( )} {isACLPEnabled && ( - + + )} + {isACLPEnabled && ( + )} {/** We don't want to break any bookmarks. This can probably be removed eventually. */} diff --git a/packages/manager/src/Root.tsx b/packages/manager/src/Root.tsx index 9d3a34c061a..2527a0f1a12 100644 --- a/packages/manager/src/Root.tsx +++ b/packages/manager/src/Root.tsx @@ -6,7 +6,7 @@ import '@fontsource/nunito-sans/700.css'; import '@fontsource/nunito-sans/800.css'; import '@fontsource/nunito-sans/400-italic.css'; import { Box } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { Outlet } from '@tanstack/react-router'; import React from 'react'; @@ -26,14 +26,14 @@ import { TopMenu } from 'src/features/TopMenu/TopMenu'; import { useMutatePreferences, usePreferences, -} from 'src/queries/profile/preferences'; + useProfile, +} from '@linode/queries'; import { ENABLE_MAINTENANCE_MODE } from './constants'; import { complianceUpdateContext } from './context/complianceUpdateContext'; import { sessionExpirationContext } from './context/sessionExpirationContext'; import { switchAccountSessionContext } from './context/switchAccountSessionContext'; import { useGlobalErrors } from './hooks/useGlobalErrors'; -import { useProfile } from './queries/profile/profile'; import { useStyles } from './Root.styles'; export const Root = () => { diff --git a/packages/manager/src/Router.tsx b/packages/manager/src/Router.tsx index a8e12fd54f3..f7fcb6f48ed 100644 --- a/packages/manager/src/Router.tsx +++ b/packages/manager/src/Router.tsx @@ -1,3 +1,4 @@ +import { useAccountSettings } from '@linode/queries'; import { QueryClient } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; import * as React from 'react'; @@ -7,7 +8,6 @@ import { useGlobalErrors } from 'src/hooks/useGlobalErrors'; import { useIsACLPEnabled } from './features/CloudPulse/Utils/utils'; import { useIsDatabasesEnabled } from './features/Databases/utilities'; import { useIsPlacementGroupsEnabled } from './features/PlacementGroups/utils'; -import { useAccountSettings } from './queries/account/settings'; import { router } from './routes'; export const Router = () => { diff --git a/packages/manager/src/__data__/searchResults.ts b/packages/manager/src/__data__/searchResults.ts index 408f349a060..9dc532e6b69 100644 --- a/packages/manager/src/__data__/searchResults.ts +++ b/packages/manager/src/__data__/searchResults.ts @@ -42,7 +42,7 @@ export const searchbarResult1 = { searchText: 'result', tags: [], }, - entityType: 'linode' as any, + entityType: 'linode' as const, label: 'result1', value: '111111', }; @@ -57,6 +57,7 @@ export const searchbarResult2 = { searchText: 'result', tags: ['tag1', 'tag2'], }, + entityType: 'nodebalancer' as const, label: 'result2', value: '222222', }; diff --git a/packages/manager/src/assets/icons/chevron-down.svg b/packages/manager/src/assets/icons/chevron-down.svg new file mode 100644 index 00000000000..2b25af7401d --- /dev/null +++ b/packages/manager/src/assets/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/manager/src/assets/icons/close.svg b/packages/manager/src/assets/icons/close.svg new file mode 100644 index 00000000000..96dca5a4443 --- /dev/null +++ b/packages/manager/src/assets/icons/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/manager/src/assets/icons/entityIcons/alertsresources.svg b/packages/manager/src/assets/icons/entityIcons/alertsresources.svg new file mode 100644 index 00000000000..3c2f8b89cf9 --- /dev/null +++ b/packages/manager/src/assets/icons/entityIcons/alertsresources.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/packages/manager/src/assets/icons/longview/cpu-icon.svg b/packages/manager/src/assets/icons/longview/cpu-icon.svg index 052f4d48a17..7fbfcc2efc5 100644 --- a/packages/manager/src/assets/icons/longview/cpu-icon.svg +++ b/packages/manager/src/assets/icons/longview/cpu-icon.svg @@ -1,5 +1,5 @@ - + diff --git a/packages/manager/src/assets/icons/longview/disk.svg b/packages/manager/src/assets/icons/longview/disk.svg index ec38ea590e5..8678f294b2a 100644 --- a/packages/manager/src/assets/icons/longview/disk.svg +++ b/packages/manager/src/assets/icons/longview/disk.svg @@ -1,5 +1,5 @@ - + diff --git a/packages/manager/src/assets/icons/longview/package-icon.svg b/packages/manager/src/assets/icons/longview/package-icon.svg index b0ead5c8a77..e75911e6feb 100644 --- a/packages/manager/src/assets/icons/longview/package-icon.svg +++ b/packages/manager/src/assets/icons/longview/package-icon.svg @@ -1,5 +1,5 @@ - + diff --git a/packages/manager/src/assets/icons/longview/ram-sticks.svg b/packages/manager/src/assets/icons/longview/ram-sticks.svg index 40c4c8999e4..1d7a5fdcca0 100644 --- a/packages/manager/src/assets/icons/longview/ram-sticks.svg +++ b/packages/manager/src/assets/icons/longview/ram-sticks.svg @@ -1,5 +1,5 @@ - + diff --git a/packages/manager/src/assets/icons/longview/server-icon.svg b/packages/manager/src/assets/icons/longview/server-icon.svg index c6f66e47746..b1fc6a409c3 100644 --- a/packages/manager/src/assets/icons/longview/server-icon.svg +++ b/packages/manager/src/assets/icons/longview/server-icon.svg @@ -1,5 +1,5 @@ - + diff --git a/packages/manager/src/assets/icons/refresh.svg b/packages/manager/src/assets/icons/refresh.svg index 677cb9966bb..4864b6402c3 100644 --- a/packages/manager/src/assets/icons/refresh.svg +++ b/packages/manager/src/assets/icons/refresh.svg @@ -1,6 +1,3 @@ - - - - - - \ No newline at end of file + + + diff --git a/packages/manager/src/assets/icons/zoomin.svg b/packages/manager/src/assets/icons/zoomin.svg index 86d7a2a4f4c..1a9d0e6873d 100644 --- a/packages/manager/src/assets/icons/zoomin.svg +++ b/packages/manager/src/assets/icons/zoomin.svg @@ -1,10 +1,18 @@ - - - - - - - - - - \ No newline at end of file + + + + + + + + + + + + + + + + + + diff --git a/packages/manager/src/assets/icons/zoomout.svg b/packages/manager/src/assets/icons/zoomout.svg index fcb722675ef..173bcb9b058 100644 --- a/packages/manager/src/assets/icons/zoomout.svg +++ b/packages/manager/src/assets/icons/zoomout.svg @@ -1,10 +1,10 @@ - - - - - - - - - - \ No newline at end of file + + + + + + + + + + diff --git a/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx b/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx index d5c1c3f0121..40e1067eb5f 100644 --- a/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx +++ b/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx @@ -1,11 +1,12 @@ +import { useNotificationsQuery } from '@linode/queries'; import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { DateTime } from 'luxon'; import * as React from 'react'; -import { Link, useLocation } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; -import { useNotificationsQuery } from 'src/queries/account/notifications'; +import { Link } from 'src/components/Link'; import { getAbuseTickets } from 'src/store/selectors/getAbuseTicket'; const preferenceKey = 'abuse-tickets'; @@ -36,7 +37,7 @@ export const AbuseTicketBanner = () => { const isViewingTicket = location.pathname.match(href); return ( - + import('src/components/PasswordInput/PasswordInput') -); - -const useStyles = makeStyles()( - (theme: Theme, _params, classes) => ({ - isOptional: { - [`& .${classes.passwordInputOuter}`]: { - marginTop: 0, - }, - }, - passwordInputOuter: {}, - root: { - marginTop: theme.spacing(3), - }, - }) -); - -interface Props { - authorizedUsers?: string[]; - className?: string; - disabled?: boolean; - disabledReason?: JSX.Element | string; - diskEncryptionEnabled?: boolean; - displayDiskEncryption?: boolean; - error?: string; - handleChange: (value: string) => void; - heading?: string; - hideStrengthLabel?: boolean; - isInRebuildFlow?: boolean; - isLKELinode?: boolean; - isOptional?: boolean; - label?: string; - linodeIsInDistributedRegion?: boolean; - password: null | string; - passwordHelperText?: string; - placeholder?: string; - required?: boolean; - selectedRegion?: string; - setAuthorizedUsers?: (usernames: string[]) => void; - small?: boolean; - toggleDiskEncryptionEnabled?: () => void; -} - -interface DiskEncryptionDescriptionDeterminants { - isDistributedRegion: boolean | undefined; // Linode Create flow (region selected for a not-yet-created linode) - isInRebuildFlow: boolean | undefined; - isLKELinode: boolean | undefined; - linodeIsInDistributedRegion: boolean | undefined; // Linode Rebuild flow (linode exists already) -} - -interface DiskEncryptionDisabledReasonDeterminants { - isDistributedRegion: boolean | undefined; // Linode Create flow (region selected for a not-yet-created linode) - isInRebuildFlow: boolean | undefined; - isLKELinode: boolean | undefined; - linodeIsInDistributedRegion: boolean | undefined; // Linode Rebuild flow (linode exists already) - regionSupportsDiskEncryption: boolean; -} - -export const AccessPanel = (props: Props) => { - const { - authorizedUsers, - className, - disabled, - disabledReason, - diskEncryptionEnabled, - displayDiskEncryption, - error, - handleChange: _handleChange, - hideStrengthLabel, - isInRebuildFlow, - isLKELinode, - isOptional, - label, - linodeIsInDistributedRegion, - password, - passwordHelperText, - placeholder, - required, - selectedRegion, - setAuthorizedUsers, - toggleDiskEncryptionEnabled, - } = props; - - const { classes, cx } = useStyles(); - - const { - isDiskEncryptionFeatureEnabled, - } = useIsDiskEncryptionFeatureEnabled(); - - const regions = useRegionsQuery().data ?? []; - - const regionSupportsDiskEncryption = doesRegionSupportFeature( - selectedRegion ?? '', - regions, - 'Disk Encryption' - ); - - const isDistributedRegion = getIsDistributedRegion( - regions ?? [], - selectedRegion ?? '' - ); - - const handleChange = (e: React.ChangeEvent) => - _handleChange(e.target.value); - - const determineDiskEncryptionDescription = ({ - isDistributedRegion, - isInRebuildFlow, - isLKELinode, - linodeIsInDistributedRegion, - }: DiskEncryptionDescriptionDeterminants) => { - // Linode Rebuild flow descriptions - if (isInRebuildFlow) { - // the order is significant: all Distributed instances are encrypted (broadest) - if (linodeIsInDistributedRegion) { - return ENCRYPT_DISK_REBUILD_DISTRIBUTED_COPY; - } - - if (isLKELinode) { - return ENCRYPT_DISK_REBUILD_LKE_COPY; - } - - if (!isLKELinode && !linodeIsInDistributedRegion) { - return ENCRYPT_DISK_REBUILD_STANDARD_COPY; - } - } - - // Linode Create flow descriptions - return isDistributedRegion - ? DISK_ENCRYPTION_DISTRIBUTED_DESCRIPTION - : DISK_ENCRYPTION_GENERAL_DESCRIPTION; - }; - - const determineDiskEncryptionDisabledReason = ({ - isDistributedRegion, - isInRebuildFlow, - isLKELinode, - linodeIsInDistributedRegion, - regionSupportsDiskEncryption, - }: DiskEncryptionDisabledReasonDeterminants) => { - if (isInRebuildFlow) { - // the order is significant: setting can't be changed for *any* Distributed instances (broadest) - if (linodeIsInDistributedRegion) { - return ENCRYPT_DISK_DISABLED_REBUILD_DISTRIBUTED_REGION_REASON; - } - - if (isLKELinode) { - return ENCRYPT_DISK_DISABLED_REBUILD_LKE_REASON; - } - - if (!regionSupportsDiskEncryption) { - return DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY; - } - } - - // Linode Create flow disabled reasons - return isDistributedRegion - ? DISK_ENCRYPTION_DEFAULT_DISTRIBUTED_INSTANCES - : DISK_ENCRYPTION_UNAVAILABLE_IN_REGION_COPY; - }; - - /** - * Display the "Disk Encryption" section if: - * 1) the feature is enabled - * 2) "displayDiskEncryption" is explicitly passed -- - * gets used in several places, but we don't want to display Disk Encryption in all - * 3) toggleDiskEncryptionEnabled is defined - */ - const diskEncryptionJSX = - isDiskEncryptionFeatureEnabled && - displayDiskEncryption && - toggleDiskEncryptionEnabled !== undefined ? ( - <> - - toggleDiskEncryptionEnabled()} - /> - - ) : null; - - return ( - - {isDiskEncryptionFeatureEnabled && ( - ({ paddingBottom: theme.spacing(2) })} - variant="h2" - > - Security - - )} - }> - - - {setAuthorizedUsers !== undefined && authorizedUsers !== undefined ? ( - <> - - - - ) : null} - {diskEncryptionJSX} - - ); -}; diff --git a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.test.tsx b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.test.tsx index 52eb7865ec7..d0885e58d39 100644 --- a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.test.tsx +++ b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.test.tsx @@ -8,7 +8,7 @@ import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import UserSSHKeyPanel from './UserSSHKeyPanel'; +import { UserSSHKeyPanel } from './UserSSHKeyPanel'; describe('UserSSHKeyPanel', () => { describe('restricted user', () => { @@ -22,7 +22,7 @@ describe('UserSSHKeyPanel', () => { return HttpResponse.json(makeResourcePage([])); }), http.get('*/account/users', () => { - return HttpResponse.json(makeResourcePage([]), { status: 401 }); + return HttpResponse.json(makeResourcePage([])); }) ); const { queryByTestId } = renderWithTheme( @@ -46,7 +46,7 @@ describe('UserSSHKeyPanel', () => { return HttpResponse.json(makeResourcePage(sshKeys)); }), http.get('*/account/users', () => { - return HttpResponse.json(makeResourcePage([]), { status: 401 }); + return HttpResponse.json(makeResourcePage([])); }) ); const { getByText } = renderWithTheme( diff --git a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx index 9b0a24522ec..c745a8bbb0e 100644 --- a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx +++ b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx @@ -1,4 +1,6 @@ -import { Button, Checkbox, Typography } from '@linode/ui'; +import { useAccountUsers, useProfile, useSSHKeysQuery } from '@linode/queries'; +import { Box, Button, Checkbox, Typography } from '@linode/ui'; +import { truncateAndJoinList } from '@linode/utilities'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -12,17 +14,15 @@ import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { CreateSSHKeyDrawer } from 'src/features/Profile/SSHKeys/CreateSSHKeyDrawer'; import { usePagination } from 'src/hooks/usePagination'; -import { useAccountUsers } from 'src/queries/account/users'; -import { useProfile, useSSHKeysQuery } from 'src/queries/profile/profile'; -import { truncateAndJoinList } from 'src/utilities/stringUtils'; import { Avatar } from '../Avatar/Avatar'; import { PaginationFooter } from '../PaginationFooter/PaginationFooter'; import { TableRowLoading } from '../TableRowLoading/TableRowLoading'; +import type { TypographyProps } from '@linode/ui'; import type { Theme } from '@mui/material/styles'; -export const MAX_SSH_KEYS_DISPLAY = 25; +const MAX_SSH_KEYS_DISPLAY = 25; const useStyles = makeStyles()((theme: Theme) => ({ cellCheckbox: { @@ -46,13 +46,23 @@ const useStyles = makeStyles()((theme: Theme) => ({ interface Props { authorizedUsers: string[]; disabled?: boolean; + /** + * Override the "SSH Keys" heading variant + * @default h2 + */ + headingVariant?: TypographyProps['variant']; setAuthorizedUsers: (usernames: string[]) => void; } -const UserSSHKeyPanel = (props: Props) => { +export const UserSSHKeyPanel = (props: Props) => { const { classes } = useStyles(); const theme = useTheme(); - const { authorizedUsers, disabled, setAuthorizedUsers } = props; + const { + authorizedUsers, + disabled, + headingVariant, + setAuthorizedUsers, + } = props; const [isCreateDrawerOpen, setIsCreateDrawerOpen] = React.useState( false @@ -192,8 +202,8 @@ const UserSSHKeyPanel = (props: Props) => { }; return ( - - + + SSH Keys @@ -227,8 +237,6 @@ const UserSSHKeyPanel = (props: Props) => { onClose={() => setIsCreateDrawerOpen(false)} open={isCreateDrawerOpen} /> - + ); }; - -export default React.memo(UserSSHKeyPanel); diff --git a/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx b/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx index 68e0bf1d662..5ce9f61b2ab 100644 --- a/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx +++ b/packages/manager/src/components/AccountActivation/AccountActivationLanding.tsx @@ -1,10 +1,9 @@ -import { StyledLinkButton, Typography } from '@linode/ui'; +import { ErrorState, StyledLinkButton, Typography } from '@linode/ui'; import Warning from '@mui/icons-material/CheckCircle'; import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { SupportTicketDialog } from 'src/features/Support/SupportTickets/SupportTicketDialog'; import type { AttachmentError } from 'src/features/Support/SupportTicketDetail/SupportTicketDetail'; diff --git a/packages/manager/src/components/AkamaiBanner/AkamaiBanner.test.tsx b/packages/manager/src/components/AkamaiBanner/AkamaiBanner.test.tsx index c8f16f29890..29a77a23957 100644 --- a/packages/manager/src/components/AkamaiBanner/AkamaiBanner.test.tsx +++ b/packages/manager/src/components/AkamaiBanner/AkamaiBanner.test.tsx @@ -25,7 +25,7 @@ describe('AkamaiBanner', () => { it('should display text and link', () => { const props = { - link: { text: 'Link text', url: 'https://example.com' }, + link: { text: 'Link text', url: 'https://example.com/' }, text: 'Example text', }; diff --git a/packages/manager/src/components/AkamaiBanner/AkamaiBanner.tsx b/packages/manager/src/components/AkamaiBanner/AkamaiBanner.tsx index 58835a51557..fcf66cbb97f 100644 --- a/packages/manager/src/components/AkamaiBanner/AkamaiBanner.tsx +++ b/packages/manager/src/components/AkamaiBanner/AkamaiBanner.tsx @@ -1,10 +1,10 @@ import { Box, Stack, Typography } from '@linode/ui'; +import { replaceNewlinesWithLineBreaks } from '@linode/utilities'; import { useMediaQuery, useTheme } from '@mui/material'; import * as React from 'react'; import { Link } from 'src/components/Link'; import { useFlags } from 'src/hooks/useFlags'; -import { replaceNewlinesWithLineBreaks } from 'src/utilities/replaceNewlinesWithLineBreaks'; import { StyledAkamaiLogo, diff --git a/packages/manager/src/components/AreaChart/AreaChart.stories.tsx b/packages/manager/src/components/AreaChart/AreaChart.stories.tsx index bd9f8fcf682..96b881a94fc 100644 --- a/packages/manager/src/components/AreaChart/AreaChart.stories.tsx +++ b/packages/manager/src/components/AreaChart/AreaChart.stories.tsx @@ -1,7 +1,7 @@ +import { getMetrics } from '@linode/utilities'; import React from 'react'; import { tooltipValueFormatter } from 'src/components/AreaChart/utils'; -import { getMetrics } from 'src/utilities/statMetrics'; import { AreaChart } from './AreaChart'; import { customLegendData, timeData } from './utils'; diff --git a/packages/manager/src/components/AreaChart/utils.test.ts b/packages/manager/src/components/AreaChart/utils.test.ts index 86dbe8533b3..ae75f0fe653 100644 --- a/packages/manager/src/components/AreaChart/utils.test.ts +++ b/packages/manager/src/components/AreaChart/utils.test.ts @@ -1,4 +1,4 @@ -import { determinePower } from 'src/utilities/unitConversions'; +import { determinePower } from '@linode/utilities'; import { generate12HourTicks, @@ -9,7 +9,7 @@ import { } from './utils'; import type { DataSet } from './AreaChart'; -import type { StorageSymbol } from 'src/utilities/unitConversions'; +import type { StorageSymbol } from '@linode/utilities'; const timestamp = 1704204000000; diff --git a/packages/manager/src/components/AreaChart/utils.ts b/packages/manager/src/components/AreaChart/utils.ts index 0bbcb638a0b..610e2023512 100644 --- a/packages/manager/src/components/AreaChart/utils.ts +++ b/packages/manager/src/components/AreaChart/utils.ts @@ -1,7 +1,6 @@ +import { roundTo } from '@linode/utilities'; import { DateTime } from 'luxon'; -import { roundTo } from 'src/utilities/roundTo'; - import type { DataSet } from './AreaChart'; import type { LinodeNetworkTimeData } from './types'; diff --git a/packages/manager/src/components/Avatar/Avatar.test.tsx b/packages/manager/src/components/Avatar/Avatar.test.tsx index abc5606b557..bbd256aa30e 100644 --- a/packages/manager/src/components/Avatar/Avatar.test.tsx +++ b/packages/manager/src/components/Avatar/Avatar.test.tsx @@ -13,8 +13,8 @@ const queryMocks = vi.hoisted(() => ({ useProfile: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/profile/profile', async () => { - const actual = await vi.importActual('src/queries/profile/profile'); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); return { ...actual, useProfile: queryMocks.useProfile, diff --git a/packages/manager/src/components/Avatar/Avatar.tsx b/packages/manager/src/components/Avatar/Avatar.tsx index fae766d2aa8..2f316f85378 100644 --- a/packages/manager/src/components/Avatar/Avatar.tsx +++ b/packages/manager/src/components/Avatar/Avatar.tsx @@ -4,8 +4,7 @@ import { default as _Avatar } from '@mui/material/Avatar'; import * as React from 'react'; import AkamaiWave from 'src/assets/logo/akamai-wave.svg'; -import { usePreferences } from 'src/queries/profile/preferences'; -import { useProfile } from 'src/queries/profile/profile'; +import { usePreferences, useProfile } from '@linode/queries'; import type { SxProps, Theme } from '@mui/material'; diff --git a/packages/manager/src/components/BackupStatus/BackupStatus.tsx b/packages/manager/src/components/BackupStatus/BackupStatus.tsx index 2bb8ae12dff..567f84f01c4 100644 --- a/packages/manager/src/components/BackupStatus/BackupStatus.tsx +++ b/packages/manager/src/components/BackupStatus/BackupStatus.tsx @@ -1,7 +1,6 @@ import { Tooltip, TooltipIcon, Typography } from '@linode/ui'; import Backup from '@mui/icons-material/Backup'; import * as React from 'react'; -import { Link as RouterLink } from 'react-router-dom'; import { makeStyles } from 'tss-react/mui'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; @@ -85,7 +84,7 @@ const BackupStatus = (props: Props) => { return (
- { > Scheduled - +
); @@ -123,7 +122,7 @@ const BackupStatus = (props: Props) => { return (
- { Never - +
); diff --git a/packages/manager/src/components/BarPercent/BarPercent.test.tsx b/packages/manager/src/components/BarPercent/BarPercent.test.tsx index 333af6b8644..5cd7cea8131 100644 --- a/packages/manager/src/components/BarPercent/BarPercent.test.tsx +++ b/packages/manager/src/components/BarPercent/BarPercent.test.tsx @@ -1,9 +1,27 @@ -import { getPercentage } from './BarPercent'; +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { BarPercent } from './BarPercent'; describe('BarPercent', () => { - it('getPercentage() should correctly return a percentage of max value ', () => { - expect(getPercentage(50, 100)).toBe(50); - expect(getPercentage(0, 100)).toBe(0); - expect(getPercentage(2150, 10000)).toBe(21.5); + // Component + it('should render', () => { + const { getByRole } = renderWithTheme(); + expect(getByRole('progressbar')).toBeInTheDocument(); + }); + + it('should display the right colors when customColors is provided', () => { + const { getByTestId } = renderWithTheme( + + ); + + expect(getByTestId('linear-progress').firstChild).toHaveStyle( + 'background-color: rgb(255, 0, 0)' + ); }); }); diff --git a/packages/manager/src/components/BarPercent/BarPercent.tsx b/packages/manager/src/components/BarPercent/BarPercent.tsx index 58b76e006e2..6b8a9a395cd 100644 --- a/packages/manager/src/components/BarPercent/BarPercent.tsx +++ b/packages/manager/src/components/BarPercent/BarPercent.tsx @@ -4,11 +4,32 @@ import * as React from 'react'; import { LinearProgress } from 'src/components/LinearProgress'; +import { getCustomColor, getPercentage } from './utils'; + import type { SxProps, Theme } from '@mui/material/styles'; export interface BarPercentProps { /** Additional css class to pass to the component */ className?: string; + /** + * Allows for custom colors to be applied to the bar. + * The color will be applied to the bar based on the percentage of the value to the max. + * + * @example + * ```tsx + * + * ``` + */ + customColors?: { + color: string; + percentage: number; + }[]; /** Applies styles to show that the value is being retrieved. */ isFetchingValue?: boolean; /** The maximum allowed value and should not be equal to min. */ @@ -30,6 +51,7 @@ export interface BarPercentProps { export const BarPercent = React.memo((props: BarPercentProps) => { const { className, + customColors, isFetchingValue, max, narrow, @@ -49,6 +71,7 @@ export const BarPercent = React.memo((props: BarPercentProps) => { ? 'buffer' : 'determinate' } + customColors={customColors} narrow={narrow} rounded={rounded} sx={sx} @@ -59,9 +82,6 @@ export const BarPercent = React.memo((props: BarPercentProps) => { ); }); -export const getPercentage = (value: number, max: number) => - (value / max) * 100; - const StyledDiv = styled('div')({ alignItems: 'center', display: 'flex', @@ -70,14 +90,16 @@ const StyledDiv = styled('div')({ const StyledLinearProgress = styled(LinearProgress, { label: 'StyledLinearProgress', - shouldForwardProp: omittedProps(['rounded', 'narrow']), + shouldForwardProp: omittedProps(['rounded', 'narrow', 'customColors']), })>(({ theme, ...props }) => ({ '& .MuiLinearProgress-bar2Buffer': { backgroundColor: theme.tokens.color.Green[60], }, '& .MuiLinearProgress-barColorPrimary': { // Increase contrast if we have a buffer bar - backgroundColor: props.valueBuffer + backgroundColor: props.customColors + ? getCustomColor(props.customColors, props.value ?? 0) + : props.valueBuffer ? theme.tokens.color.Green[70] : theme.tokens.color.Green[60], }, diff --git a/packages/manager/src/components/BarPercent/utils.test.ts b/packages/manager/src/components/BarPercent/utils.test.ts new file mode 100644 index 00000000000..3422c9a6757 --- /dev/null +++ b/packages/manager/src/components/BarPercent/utils.test.ts @@ -0,0 +1,16 @@ +import { getCustomColor, getPercentage } from './utils'; + +describe('BarPercent Utils', () => { + it('getPercentage() should correctly return a percentage of max value ', () => { + expect(getPercentage(50, 100)).toBe(50); + expect(getPercentage(0, 100)).toBe(0); + expect(getPercentage(2150, 10000)).toBe(21.5); + }); + + it('getCustomColor() should correctly return a color based on the percentage', () => { + expect(getCustomColor([{ color: 'red', percentage: 50 }], 50)).toBe('red'); + expect(getCustomColor([{ color: 'red', percentage: 50 }], 25)).toBe( + undefined + ); + }); +}); diff --git a/packages/manager/src/components/BarPercent/utils.ts b/packages/manager/src/components/BarPercent/utils.ts new file mode 100644 index 00000000000..606749eaf83 --- /dev/null +++ b/packages/manager/src/components/BarPercent/utils.ts @@ -0,0 +1,16 @@ +import type { BarPercentProps } from './BarPercent'; + +export const getPercentage = (value: number, max: number) => + (value / max) * 100; + +export const getCustomColor = ( + customColors: BarPercentProps['customColors'], + percentage: number +) => { + if (!customColors) { + return undefined; + } + + const color = customColors.find((color) => percentage >= color.percentage); + return color?.color; +}; diff --git a/packages/manager/src/components/Breadcrumb/Crumbs.tsx b/packages/manager/src/components/Breadcrumb/Crumbs.tsx index 5d291bc54cd..174d86166c4 100644 --- a/packages/manager/src/components/Breadcrumb/Crumbs.tsx +++ b/packages/manager/src/components/Breadcrumb/Crumbs.tsx @@ -1,5 +1,5 @@ -import { LocationDescriptor } from 'history'; import * as React from 'react'; +// eslint-disable-next-line no-restricted-imports import { Link } from 'react-router-dom'; import { @@ -9,11 +9,13 @@ import { } from './Crumbs.styles'; import { FinalCrumb } from './FinalCrumb'; import { FinalCrumbPrefix } from './FinalCrumbPrefix'; -import { EditableProps, LabelProps } from './types'; + +import type { EditableProps, LabelProps } from './types'; +import type { LinkProps } from 'react-router-dom'; export interface CrumbOverridesProps { label?: string; - linkTo?: LocationDescriptor; + linkTo?: LinkProps['to']; noCap?: boolean; position: number; } diff --git a/packages/manager/src/components/CheckoutBar/CheckoutBar.test.tsx b/packages/manager/src/components/CheckoutBar/CheckoutBar.test.tsx index e813e00c175..d503d9a9f97 100644 --- a/packages/manager/src/components/CheckoutBar/CheckoutBar.test.tsx +++ b/packages/manager/src/components/CheckoutBar/CheckoutBar.test.tsx @@ -50,12 +50,12 @@ describe('CheckoutBar', () => { }); it('should disable submit button and show loading icon if isMakingRequest is true', () => { - const { getByTestId } = renderWithTheme( + const { getByTestId, getByRole } = renderWithTheme( ); expect(getByTestId('button')).toBeDisabled(); - expect(getByTestId('loadingIcon')).toBeInTheDocument(); + expect(getByRole('progressbar')).toBeInTheDocument(); }); it("should disable submit button and show 'Submit' text if disabled prop is set", () => { diff --git a/packages/manager/src/components/CheckoutBar/CheckoutBar.tsx b/packages/manager/src/components/CheckoutBar/CheckoutBar.tsx index 95673b01d64..e41a3c62d3f 100644 --- a/packages/manager/src/components/CheckoutBar/CheckoutBar.tsx +++ b/packages/manager/src/components/CheckoutBar/CheckoutBar.tsx @@ -12,6 +12,10 @@ import { } from './styles'; export interface CheckoutBarProps { + /** + * Additional pricing to display after the calculated total + */ + additionalPricing?: JSX.Element; /** * JSX element to be displayed as an agreement section. */ @@ -61,6 +65,7 @@ export interface CheckoutBarProps { const CheckoutBar = (props: CheckoutBarProps) => { const { + additionalPricing, agreement, calculatedPrice, children, @@ -95,7 +100,10 @@ const CheckoutBar = (props: CheckoutBarProps) => { { {(price >= 0 && !disabled) || price ? ( - + <> + + {additionalPricing} + ) : ( {priceSelectionText} )} diff --git a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx index 9ba6994f259..61591534151 100644 --- a/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx +++ b/packages/manager/src/components/CheckoutSummary/CheckoutSummary.tsx @@ -1,7 +1,7 @@ import { Paper, Typography } from '@linode/ui'; import { useTheme } from '@mui/material'; import { styled } from '@mui/material/styles'; -import Grid2 from '@mui/material/Unstable_Grid2/Grid2'; +import Grid2 from '@mui/material/Grid2'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; @@ -51,11 +51,7 @@ export const CheckoutSummary = (props: CheckoutSummaryProps) => { Please configure your Linode. ) : null} - + {displaySections.map((item) => ( ))} @@ -78,10 +74,12 @@ const StyledHeading = styled(Typography)(({ theme }) => ({ const StyledSummary = styled(Grid2)(({ theme }) => ({ [theme.breakpoints.up('md')]: { '& > div': { - '&:last-child': { - borderRight: 'none', + '&:first-child': { + borderLeft: 'none', + paddingLeft: 0, }, - borderRight: `solid 1px ${theme.tokens.color.Neutrals[50]}`, + borderLeft: `solid 1px ${theme.tokens.color.Neutrals[50]}`, + padding: `0 ${theme.spacing(1.5)}`, }, }, })); diff --git a/packages/manager/src/components/CheckoutSummary/SummaryItem.tsx b/packages/manager/src/components/CheckoutSummary/SummaryItem.tsx index 9264e43b474..a970e161b6c 100644 --- a/packages/manager/src/components/CheckoutSummary/SummaryItem.tsx +++ b/packages/manager/src/components/CheckoutSummary/SummaryItem.tsx @@ -1,6 +1,6 @@ import { Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import Grid2 from '@mui/material/Unstable_Grid2/Grid2'; +import Grid2 from '@mui/material/Grid2'; import React from 'react'; import type { SummaryItem as Props } from './CheckoutSummary'; diff --git a/packages/manager/src/components/CollapsibleTable/CollapsibleRow.tsx b/packages/manager/src/components/CollapsibleTable/CollapsibleRow.tsx index 9aa34f5bd58..de314b69f48 100644 --- a/packages/manager/src/components/CollapsibleTable/CollapsibleRow.tsx +++ b/packages/manager/src/components/CollapsibleTable/CollapsibleRow.tsx @@ -23,7 +23,12 @@ export const CollapsibleRow = (props: Props) => { <> - + setOpen(!open)} diff --git a/packages/manager/src/components/ColorPalette/ColorPalette.tsx b/packages/manager/src/components/ColorPalette/ColorPalette.tsx index 34a7c61d560..f9017a88e21 100644 --- a/packages/manager/src/components/ColorPalette/ColorPalette.tsx +++ b/packages/manager/src/components/ColorPalette/ColorPalette.tsx @@ -1,7 +1,7 @@ import { Typography as FontTypography } from '@linode/design-language-system'; import { Typography } from '@linode/ui'; import { useTheme } from '@mui/material'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -167,7 +167,15 @@ export const ColorPalette = () => { const createSwatch = (color: string, alias: string) => { return ( - +
{alias} @@ -181,7 +189,7 @@ export const ColorPalette = () => { const renderColor = (heading: string, colors: Color[]) => { return ( <> - + {heading} {colors.map((color) => createSwatch(color.color, color.alias))} diff --git a/packages/manager/src/components/ColorPicker/ColorPicker.tsx b/packages/manager/src/components/ColorPicker/ColorPicker.tsx index 6b611c91ee7..4cd6ea1b03c 100644 --- a/packages/manager/src/components/ColorPicker/ColorPicker.tsx +++ b/packages/manager/src/components/ColorPicker/ColorPicker.tsx @@ -30,7 +30,7 @@ export const ColorPicker = (props: ColorPickerProps) => { const theme = useTheme(); const [color, setColor] = useState( - defaultColor ?? theme.palette.primary.dark + defaultColor ?? theme.tokens.color.Neutrals[30] ); return ( diff --git a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx index 122c6cd56c3..302a93a8d1c 100644 --- a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx +++ b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.stories.tsx @@ -1,7 +1,7 @@ +import { ActionsPanel } from '@linode/ui'; import { action } from '@storybook/addon-actions'; import React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import type { Meta, StoryObj } from '@storybook/react'; diff --git a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.test.tsx b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.test.tsx index f42b2eafa45..d814a944edf 100644 --- a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.test.tsx +++ b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.test.tsx @@ -1,9 +1,9 @@ +import { ActionsPanel } from '@linode/ui'; import { fireEvent } from '@testing-library/react'; import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { ActionsPanel } from '../ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from './ConfirmationDialog'; import type { ConfirmationDialogProps } from './ConfirmationDialog'; diff --git a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx index 41449a61be9..7fd5211fe9b 100644 --- a/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx +++ b/packages/manager/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -29,12 +29,7 @@ export const ConfirmationDialog = React.forwardRef< return ( {children} - + {actions && typeof actions === 'function' ? actions(dialogProps) : actions} diff --git a/packages/manager/src/components/CopyableTextField/CopyableTextField.test.tsx b/packages/manager/src/components/CopyableTextField/CopyableTextField.test.tsx index 0c849c41cbb..a37bed88cca 100644 --- a/packages/manager/src/components/CopyableTextField/CopyableTextField.test.tsx +++ b/packages/manager/src/components/CopyableTextField/CopyableTextField.test.tsx @@ -1,14 +1,15 @@ +import { downloadFile } from '@linode/utilities'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { downloadFile } from 'src/utilities/downloadFile'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { CopyableTextField } from './CopyableTextField'; import type { CopyableTextFieldProps } from './CopyableTextField'; -vi.mock('src/utilities/downloadFile', () => ({ +vi.mock('@linode/utilities', async () => ({ + ...(await vi.importActual('@linode/utilities')), downloadFile: vi.fn(), })); diff --git a/packages/manager/src/components/Currency/Currency.tsx b/packages/manager/src/components/Currency/Currency.tsx index 5bf4cf71460..05e821e31dc 100644 --- a/packages/manager/src/components/Currency/Currency.tsx +++ b/packages/manager/src/components/Currency/Currency.tsx @@ -1,7 +1,6 @@ +import { isNumber } from '@linode/utilities'; import * as React from 'react'; -import { isNumber } from 'src/utilities/isNumber'; - interface CurrencyFormatterProps { /** * Additional data attributes to pass in. For example, a data-testid diff --git a/packages/manager/src/components/DatePicker/DateTimePicker.tsx b/packages/manager/src/components/DatePicker/DateTimePicker.tsx index f572a866437..d46e281df1a 100644 --- a/packages/manager/src/components/DatePicker/DateTimePicker.tsx +++ b/packages/manager/src/components/DatePicker/DateTimePicker.tsx @@ -1,4 +1,4 @@ -import { InputAdornment, TextField } from '@linode/ui'; +import { ActionsPanel, InputAdornment, TextField } from '@linode/ui'; import { Divider } from '@linode/ui'; import { Box } from '@linode/ui'; import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; @@ -10,7 +10,6 @@ import { TimePicker } from '@mui/x-date-pickers/TimePicker'; import React, { useEffect, useState } from 'react'; import { timezones } from 'src/assets/timezones/timezones'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { TimeZoneSelect } from './TimeZoneSelect'; diff --git a/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx b/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx index 812bff2f499..467a8ec9053 100644 --- a/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx +++ b/packages/manager/src/components/DateTimeDisplay/DateTimeDisplay.tsx @@ -1,7 +1,7 @@ import { Typography } from '@linode/ui'; import * as React from 'react'; -import { useProfile } from 'src/queries/profile/profile'; +import { useProfile } from '@linode/queries'; import { formatDate } from 'src/utilities/formatDate'; import type { TimeInterval } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx index 2c044a0d631..2bf259bcc1f 100644 --- a/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx +++ b/packages/manager/src/components/DebouncedSearchTextField/DebouncedSearchTextField.tsx @@ -4,15 +4,18 @@ import { InputAdornment, TextField, } from '@linode/ui'; -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 type { TextFieldProps } from '@linode/ui'; +import Close from 'src/assets/icons/close.svg'; +import Search from 'src/assets/icons/search.svg'; + +import type { InputProps, TextFieldProps } from '@linode/ui'; export interface DebouncedSearchProps extends TextFieldProps { + /** + * Class name to apply to the component. + */ className?: string; /** * Whether to show a clear button at the end of the input. @@ -23,9 +26,18 @@ export interface DebouncedSearchProps extends TextFieldProps { * @default 400 */ debounceTime?: number; + /** + * Default value of the input. + */ defaultValue?: string; + /** + * Whether to hide the label. + */ hideLabel?: boolean; - + /** + * Custom props to apply to the input element. + */ + inputSlotProps?: InputProps; /** * Determines if the textbox is currently searching for inputted query */ @@ -34,19 +46,25 @@ export interface DebouncedSearchProps extends TextFieldProps { * Function to perform when searching for query */ onSearch: (query: string) => void; + /** + * Placeholder text for the input. + */ placeholder?: string; + /** + * Value of the input. + */ value: string; } export const DebouncedSearchTextField = React.memo( (props: DebouncedSearchProps) => { const { - InputProps, className, clearable, debounceTime, defaultValue, hideLabel, + inputSlotProps, isSearching, label, onSearch, @@ -76,38 +94,35 @@ export const DebouncedSearchTextField = React.memo( return ( - - - ) : ( - clearable && - textFieldValue && ( - { - setTextFieldValue(''); - onSearch(''); - }} - aria-label="Clear" - size="small" - > - ({ - '&&': { - color: theme.color.grey1, - }, - })} - /> - - ) - ), - startAdornment: ( - - - - ), - ...InputProps, + slotProps={{ + input: { + endAdornment: ( + + {isSearching && } + {clearable && Boolean(textFieldValue) && ( + { + setTextFieldValue(''); + onSearch(''); + }} + sx={{ + padding: 0, + }} + aria-label="Clear" + size="small" + > + + + )} + + ), + startAdornment: ( + + + + ), + ...inputSlotProps, + }, }} className={className} data-qa-debounced-search @@ -122,9 +137,3 @@ export const DebouncedSearchTextField = React.memo( ); } ); - -const StyledSearchIcon = styled(Search)(({ theme }) => ({ - '&&, &&:hover': { - color: theme.color.grey1, - }, -})); diff --git a/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx b/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx index 58470598a58..d86ca80e94b 100644 --- a/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx +++ b/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx @@ -6,7 +6,7 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { DeletionDialog } from './DeletionDialog'; import type { DeletionDialogProps } from './DeletionDialog'; -import type { ManagerPreferences } from 'src/types/ManagerPreferences'; +import type { ManagerPreferences } from '@linode/utilities'; const preference: ManagerPreferences['type_to_confirm'] = true; @@ -14,8 +14,8 @@ const queryMocks = vi.hoisted(() => ({ usePreferences: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/profile/preferences', async () => { - const actual = await vi.importActual('src/queries/profile/preferences'); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); return { ...actual, usePreferences: queryMocks.usePreferences, @@ -132,9 +132,7 @@ describe('DeletionDialog', () => { expect(deleteButton).toBeInTheDocument(); expect(deleteButton).toBeDisabled(); - const loadingSvgIcon = deleteButton.querySelector( - '[data-testid="loadingIcon"]' - ); + const loadingSvgIcon = deleteButton.querySelector('[role="progressbar"]'); expect(loadingSvgIcon).toBeInTheDocument(); }); diff --git a/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx b/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx index 0bee2554ae3..7a553e87e41 100644 --- a/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx +++ b/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx @@ -1,13 +1,12 @@ -import { Notice, Typography } from '@linode/ui'; +import { ActionsPanel, Notice, Typography } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { TypeToConfirm } from 'src/components/TypeToConfirm/TypeToConfirm'; import { titlecase } from 'src/features/Linodes/presentation'; -import { usePreferences } from 'src/queries/profile/preferences'; -import { capitalize } from 'src/utilities/capitalize'; +import { usePreferences } from '@linode/queries'; import type { DialogProps } from '@linode/ui'; diff --git a/packages/manager/src/components/DescriptionList/DescriptionList.styles.ts b/packages/manager/src/components/DescriptionList/DescriptionList.styles.ts index 681a258a37e..83b3939ca91 100644 --- a/packages/manager/src/components/DescriptionList/DescriptionList.styles.ts +++ b/packages/manager/src/components/DescriptionList/DescriptionList.styles.ts @@ -1,10 +1,10 @@ import { Typography, omittedProps } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import type { DescriptionListProps } from './DescriptionList'; import type { TypographyProps } from '@mui/material'; -import type { Grid2Props } from '@mui/material/Unstable_Grid2'; +import type { Grid2Props } from '@mui/material/Grid2'; interface StyledDLProps extends Omit { component: Grid2Props['component']; diff --git a/packages/manager/src/components/DocumentTitle/DocumentTitle.tsx b/packages/manager/src/components/DocumentTitle/DocumentTitle.tsx index 10d87b7afd8..546d75b8186 100644 --- a/packages/manager/src/components/DocumentTitle/DocumentTitle.tsx +++ b/packages/manager/src/components/DocumentTitle/DocumentTitle.tsx @@ -6,11 +6,10 @@ the Linodes landing page. More context: https://github.com/linode/manager/pull/9 */ +import { usePrevious } from '@linode/utilities'; import { reverse } from 'ramda'; import * as React from 'react'; -import { usePrevious } from 'src/hooks/usePrevious'; - interface DocumentTitleSegmentsContext { appendSegment: (segment: string) => void; removeSegment: (segment: string) => void; diff --git a/packages/manager/src/components/DownloadTooltip.tsx b/packages/manager/src/components/DownloadTooltip.tsx index dfcd502bb94..6aeb1688cf5 100644 --- a/packages/manager/src/components/DownloadTooltip.tsx +++ b/packages/manager/src/components/DownloadTooltip.tsx @@ -1,9 +1,9 @@ import { Tooltip, Typography } from '@linode/ui'; +import { downloadFile } from '@linode/utilities'; import * as React from 'react'; import FileDownload from 'src/assets/icons/download.svg'; import { StyledIconButton } from 'src/components/CopyTooltip/CopyTooltip'; -import { downloadFile } from 'src/utilities/downloadFile'; interface Props { /** diff --git a/packages/manager/src/components/EditableEntityLabel/EditableEntityLabel.tsx b/packages/manager/src/components/EditableEntityLabel/EditableEntityLabel.tsx index 5e9048e0c63..13d33101bce 100644 --- a/packages/manager/src/components/EditableEntityLabel/EditableEntityLabel.tsx +++ b/packages/manager/src/components/EditableEntityLabel/EditableEntityLabel.tsx @@ -1,6 +1,6 @@ import { Typography } from '@linode/ui'; import { styled, useTheme } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { EntityIcon } from 'src/components/EntityIcon/EntityIcon'; @@ -48,15 +48,15 @@ export const EditableEntityLabel = (props: EditableEntityLabelProps) => { return ( {!isEditing && iconVariant && ( @@ -86,7 +86,7 @@ export const EditableEntityLabel = (props: EditableEntityLabelProps) => { /> {subText && !isEditing && ( - + {subText} )} diff --git a/packages/manager/src/components/Encryption/Encryption.tsx b/packages/manager/src/components/Encryption/Encryption.tsx index 84daa821289..20224a52bc3 100644 --- a/packages/manager/src/components/Encryption/Encryption.tsx +++ b/packages/manager/src/components/Encryption/Encryption.tsx @@ -28,7 +28,7 @@ export const Encryption = (props: EncryptionProps) => { } = props; return ( - <> + {`${entityType ?? 'Disk'} Encryption`} @@ -80,6 +80,6 @@ export const Encryption = (props: EncryptionProps) => { toolTipText={disabled ? disabledReason : ''} /> - + ); }; diff --git a/packages/manager/src/components/Encryption/utils.ts b/packages/manager/src/components/Encryption/utils.ts index c0347f55ada..36ea00662bd 100644 --- a/packages/manager/src/components/Encryption/utils.ts +++ b/packages/manager/src/components/Encryption/utils.ts @@ -1,5 +1,14 @@ +import { useAccount } from '@linode/queries'; + import { useFlags } from 'src/hooks/useFlags'; -import { useAccount } from 'src/queries/account/account'; + +import { + ENCRYPT_DISK_DISABLED_REBUILD_DISTRIBUTED_REGION_REASON, + ENCRYPT_DISK_DISABLED_REBUILD_LKE_REASON, + ENCRYPT_DISK_REBUILD_DISTRIBUTED_COPY, + ENCRYPT_DISK_REBUILD_LKE_COPY, + ENCRYPT_DISK_REBUILD_STANDARD_COPY, +} from './constants'; /** * Hook to determine if the Disk Encryption feature should be visible to the user. @@ -58,3 +67,46 @@ export const useIsBlockStorageEncryptionFeatureEnabled = (): { return { isBlockStorageEncryptionFeatureEnabled }; }; + +interface RebuildEncryptionDescriptionOptions { + isLKELinode: boolean; + isLinodeInDistributedRegion: boolean; +} + +export function getRebuildDiskEncryptionDescription( + options: RebuildEncryptionDescriptionOptions +) { + if (options.isLinodeInDistributedRegion) { + return ENCRYPT_DISK_REBUILD_DISTRIBUTED_COPY; + } + + if (options.isLKELinode) { + return ENCRYPT_DISK_REBUILD_LKE_COPY; + } + + return ENCRYPT_DISK_REBUILD_STANDARD_COPY; +} + +interface DiskEncryptionDisabledInRebuildFlowOptions { + isLKELinode: boolean | undefined; + isLinodeInDistributedRegion: boolean; + regionSupportsDiskEncryption: boolean; +} + +export const getDiskEncryptionDisabledInRebuildReason = ( + options: DiskEncryptionDisabledInRebuildFlowOptions +) => { + if (options.isLinodeInDistributedRegion) { + return ENCRYPT_DISK_DISABLED_REBUILD_DISTRIBUTED_REGION_REASON; + } + + if (options.isLKELinode) { + return ENCRYPT_DISK_DISABLED_REBUILD_LKE_REASON; + } + + if (!options.regionSupportsDiskEncryption) { + return "Disk encryption is not available in this Linode's region."; + } + + return undefined; +}; diff --git a/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx b/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx index 026e5ec82e1..c1a8e1b4548 100644 --- a/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx +++ b/packages/manager/src/components/EnhancedNumberInput/EnhancedNumberInput.tsx @@ -142,7 +142,7 @@ const StyledButton = styled(Button)(({ theme }) => ({ border: 'none', }, border: 'none', - borderRadius: theme.tokens.borderRadius.None, + borderRadius: theme.tokens.alias.Radius.Default, height: 34, minHeight: 'fit-content', minWidth: 30, diff --git a/packages/manager/src/components/EntityDetail/EntityDetail.tsx b/packages/manager/src/components/EntityDetail/EntityDetail.tsx index a72f98c7a77..7d6524ae473 100644 --- a/packages/manager/src/components/EntityDetail/EntityDetail.tsx +++ b/packages/manager/src/components/EntityDetail/EntityDetail.tsx @@ -1,6 +1,6 @@ import { omittedProps } from '@linode/ui'; +import Grid from '@mui/material/Grid2'; import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; export interface EntityDetailProps { @@ -27,13 +27,13 @@ export const EntityDetail = (props: EntityDetailProps) => { {body} )} {footer !== undefined && ( - + {footer} )} diff --git a/packages/manager/src/components/EntityIcon/EntityIcon.stories.tsx b/packages/manager/src/components/EntityIcon/EntityIcon.stories.tsx index 6af1358d907..a70ef8e32fc 100644 --- a/packages/manager/src/components/EntityIcon/EntityIcon.stories.tsx +++ b/packages/manager/src/components/EntityIcon/EntityIcon.stories.tsx @@ -1,5 +1,5 @@ import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import React from 'react'; import { EntityIcon } from 'src/components/EntityIcon/EntityIcon'; @@ -35,13 +35,20 @@ const sxGridItem = { export const Default: Story = { render: (args) => ( - - + + {args.variant} - + All Variants {variantList.map((variant, idx) => { diff --git a/packages/manager/src/components/ErrorMessage.tsx b/packages/manager/src/components/ErrorMessage.tsx index aae52c9770d..53f3c8e78af 100644 --- a/packages/manager/src/components/ErrorMessage.tsx +++ b/packages/manager/src/components/ErrorMessage.tsx @@ -39,5 +39,5 @@ export const ErrorMessage = (props: Props) => { return ; } - return {message}; + return {message}; }; diff --git a/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx b/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx index 49fb74774dd..b6378219940 100644 --- a/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx +++ b/packages/manager/src/components/GenerateFirewallDialog/GenerateFirewallDialog.tsx @@ -1,8 +1,8 @@ import { Button, Dialog, Notice, Stack, Typography } from '@linode/ui'; +import { replaceNewlinesWithLineBreaks } from '@linode/utilities'; import React from 'react'; import { useFlags } from 'src/hooks/useFlags'; -import { replaceNewlinesWithLineBreaks } from 'src/utilities/replaceNewlinesWithLineBreaks'; import { ErrorMessage } from '../ErrorMessage'; import { LinearProgress } from '../LinearProgress'; diff --git a/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts b/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts index 434c4f04cd0..ffd698de55a 100644 --- a/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts +++ b/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts @@ -1,7 +1,6 @@ import { useQueryClient } from '@tanstack/react-query'; -import { firewallQueries } from 'src/queries/firewalls'; -import { useCreateFirewall } from 'src/queries/firewalls'; +import { firewallQueries, useCreateFirewall } from '@linode/queries'; import type { DialogState } from './GenerateFirewallDialog'; import type { @@ -45,14 +44,16 @@ export const useCreateFirewallFromTemplate = (options: { }; }; -const createFirewallFromTemplate = async (options: { +export const createFirewallFromTemplate = async (options: { createFirewall: (firewall: CreateFirewallPayload) => Promise; queryClient: QueryClient; templateSlug: FirewallTemplateSlug; - updateProgress: (progress: number | undefined) => void; + updateProgress?: (progress: number | undefined) => void; }): Promise => { const { createFirewall, queryClient, templateSlug, updateProgress } = options; - updateProgress(0); + if (updateProgress) { + updateProgress(0); + } await new Promise((resolve) => setTimeout(resolve, 0)); // return control to the DOM to update the progress // Get firewalls and firewall template in parallel @@ -60,8 +61,10 @@ const createFirewallFromTemplate = async (options: { queryClient.ensureQueryData(firewallQueries.template(templateSlug)), queryClient.fetchQuery(firewallQueries.firewalls._ctx.all), // must fetch fresh data if generating more than one firewall ]); - updateProgress(80); // this gives the appearance of linear progress + if (updateProgress) { + updateProgress(80); // this gives the appearance of linear progress + } // Determine new firewall name const label = getUniqueFirewallLabel(slug, firewalls); diff --git a/packages/manager/src/components/IPSelect/IPSelect.tsx b/packages/manager/src/components/IPSelect/IPSelect.tsx index 51ab2e9671c..8c712690d1b 100644 --- a/packages/manager/src/components/IPSelect/IPSelect.tsx +++ b/packages/manager/src/components/IPSelect/IPSelect.tsx @@ -1,7 +1,7 @@ import { Autocomplete } from '@linode/ui'; import * as React from 'react'; -import { useLinodeQuery } from 'src/queries/linodes/linodes'; +import { useLinodeQuery } from '@linode/queries'; interface Option { label: string; @@ -56,6 +56,7 @@ export const IPSelect = (props: Props) => { errorText={errorText} label="IP Address" loading={isLoading} + noMarginTop onChange={(_, selected) => handleChange(selected.value)} options={options} placeholder="Select an IP Address..." diff --git a/packages/manager/src/components/IconTextLink/IconTextLink.tsx b/packages/manager/src/components/IconTextLink/IconTextLink.tsx index 0a19f86edae..2cd7b0f3079 100644 --- a/packages/manager/src/components/IconTextLink/IconTextLink.tsx +++ b/packages/manager/src/components/IconTextLink/IconTextLink.tsx @@ -1,8 +1,9 @@ import { Button } from '@linode/ui'; import * as React from 'react'; -import { Link } from 'react-router-dom'; import { makeStyles } from 'tss-react/mui'; +import { Link } from 'src/components/Link'; + import type { Theme } from '@mui/material/styles'; import type { SvgIcon } from 'src/components/SvgIcon'; @@ -18,23 +19,9 @@ const useStyles = makeStyles()((theme: Theme) => ({ color: theme.tokens.color.Neutrals[50], pointerEvents: 'none', }, - icon: { - '& .border': { - transition: 'none', - }, - color: 'inherit', - fontSize: 18, - marginRight: theme.spacing(0.5), - transition: 'none', - }, label: { - position: 'relative', - top: -1, whiteSpace: 'nowrap', }, - left: { - left: `-${theme.spacing(1.5)}`, - }, linkWrapper: { '&:hover, &:focus': { textDecoration: 'none', @@ -43,22 +30,11 @@ const useStyles = makeStyles()((theme: Theme) => ({ justifyContent: 'center', }, root: { - '&:focus': { outline: `1px dotted ${theme.tokens.color.Neutrals[50]}` }, - '&:hover': { - '& .border': { - color: theme.palette.primary.light, - }, - backgroundColor: 'transparent', - color: theme.palette.primary.light, - }, - alignItems: 'flex-start', - borderRadius: theme.tokens.borderRadius.None, - cursor: 'pointer', + alignItems: 'center', + borderRadius: theme.tokens.alias.Radius.Default, display: 'flex', - margin: `0 ${theme.spacing(1)} 2px 0`, - minHeight: 'auto', - padding: theme.spacing(1.5), - transition: 'none', + gap: theme.spacing(2), + padding: theme.spacing(0.5), }, })); @@ -68,8 +44,6 @@ export interface Props { children?: string; className?: string; disabled?: boolean; - hideText?: boolean; - left?: boolean; onClick?: () => void; text: string; title: string; @@ -83,8 +57,6 @@ export const IconTextLink = (props: Props) => { active, className, disabled, - hideText, - left, onClick, text, title, @@ -98,7 +70,6 @@ export const IconTextLink = (props: Props) => { { [classes.active]: active, [classes.disabled]: disabled, - [classes.left]: left, }, className )} @@ -107,14 +78,8 @@ export const IconTextLink = (props: Props) => { onClick={onClick} title={title} > - - - {text} - + + {text} ); diff --git a/packages/manager/src/components/ImageSelect/ImageSelect.tsx b/packages/manager/src/components/ImageSelect/ImageSelect.tsx index 440e9f64130..139019aa04f 100644 --- a/packages/manager/src/components/ImageSelect/ImageSelect.tsx +++ b/packages/manager/src/components/ImageSelect/ImageSelect.tsx @@ -1,4 +1,11 @@ -import { Autocomplete, Box, Notice, Stack, Typography } from '@linode/ui'; +import { + Autocomplete, + Box, + InputAdornment, + Notice, + Stack, + Typography, +} from '@linode/ui'; import { DateTime } from 'luxon'; import React, { useMemo } from 'react'; @@ -178,15 +185,15 @@ export const ImageSelect = (props: Props) => { InputProps: { startAdornment: !multiple && value && !Array.isArray(value) ? ( - + + + ) : null, }, }} diff --git a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx index bf85e77865a..1a64c04487b 100644 --- a/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx +++ b/packages/manager/src/components/InlineMenuAction/InlineMenuAction.tsx @@ -2,7 +2,8 @@ import { StyledActionButton } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { Link } from 'react-router-dom'; + +import { Link } from 'src/components/Link'; interface InlineMenuActionProps { /** Required action text */ diff --git a/packages/manager/src/components/LandingHeader/LandingHeader.tsx b/packages/manager/src/components/LandingHeader/LandingHeader.tsx index 4560d7e00ca..34bbdf06bd8 100644 --- a/packages/manager/src/components/LandingHeader/LandingHeader.tsx +++ b/packages/manager/src/components/LandingHeader/LandingHeader.tsx @@ -1,6 +1,6 @@ import { Button } from '@linode/ui'; import { styled, useTheme } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; @@ -76,11 +76,13 @@ export const LandingHeader = ({ return ( {betaFeedbackLink && ( { diff --git a/packages/manager/src/components/LineGraph/MetricsDisplay.tsx b/packages/manager/src/components/LineGraph/MetricsDisplay.tsx index 53dc61a932f..c579fd7784c 100644 --- a/packages/manager/src/components/LineGraph/MetricsDisplay.tsx +++ b/packages/manager/src/components/LineGraph/MetricsDisplay.tsx @@ -9,7 +9,7 @@ import { TableRow } from 'src/components/TableRow'; import { StyledLegend } from './MetricsDisplay.styles'; -import type { Metrics } from 'src/utilities/statMetrics'; +import type { Metrics } from '@linode/utilities'; const ROW_HEADERS = ['Max', 'Avg', 'Last'] as const; diff --git a/packages/manager/src/components/Link.test.tsx b/packages/manager/src/components/Link.test.tsx index d977e1cb2aa..fc26fdbb014 100644 --- a/packages/manager/src/components/Link.test.tsx +++ b/packages/manager/src/components/Link.test.tsx @@ -37,7 +37,7 @@ describe('Link component', () => { expect(linkElement.tagName).toBe('A'); expect(linkElement).toHaveAttribute('rel', 'noopener noreferrer'); expect(linkElement).toHaveAttribute('target', '_blank'); - expect(linkElement.getAttribute('href')).toBe('https://example.com'); + expect(linkElement.getAttribute('href')).toBe('https://example.com/'); expect(linkElement.getAttribute('target')).toBe('_blank'); expect(linkElement).toHaveTextContent(/External Link/); }); @@ -55,7 +55,7 @@ describe('Link component', () => { expect(linkElement.tagName).toBe('A'); expect(linkElement).toHaveAttribute('rel', 'noopener noreferrer'); expect(linkElement).toHaveAttribute('target', '_blank'); - expect(linkElement.getAttribute('href')).toBe('https://example.com'); + expect(linkElement.getAttribute('href')).toBe('https://example.com/'); expect(linkElement.getAttribute('target')).toBe('_blank'); expect(linkElement).toHaveTextContent(/External Link/); }); diff --git a/packages/manager/src/components/Link.tsx b/packages/manager/src/components/Link.tsx index ccdd26198a9..73c16ce0648 100644 --- a/packages/manager/src/components/Link.tsx +++ b/packages/manager/src/components/Link.tsx @@ -1,15 +1,16 @@ import { sanitizeUrl } from '@braintree/sanitize-url'; import { omitProps } from '@linode/ui'; +import { + childrenContainsNoText, + flattenChildrenIntoAriaLabel, + opensInNewTab, +} from '@linode/utilities'; // `link.ts` utils from @linode/utilities import * as React from 'react'; +// eslint-disable-next-line no-restricted-imports import { Link as RouterLink } from 'react-router-dom'; import ExternalLinkIcon from 'src/assets/icons/external-link.svg'; import { useStyles } from 'src/components/Link.styles'; -import { - childrenContainsNoText, - flattenChildrenIntoAriaLabel, - opensInNewTab, -} from 'src/utilities/link'; import type { LinkProps as TanStackLinkProps } from '@tanstack/react-router'; import type { LinkProps as _LinkProps } from 'react-router-dom'; diff --git a/packages/manager/src/components/LongviewLineGraph/LongviewLineGraph.tsx b/packages/manager/src/components/LongviewLineGraph/LongviewLineGraph.tsx index 6946cd54d96..abe23a9983a 100644 --- a/packages/manager/src/components/LongviewLineGraph/LongviewLineGraph.tsx +++ b/packages/manager/src/components/LongviewLineGraph/LongviewLineGraph.tsx @@ -1,8 +1,7 @@ -import { Divider, Typography } from '@linode/ui'; +import { Divider, ErrorState, Typography } from '@linode/ui'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LineGraph } from 'src/components/LineGraph/LineGraph'; import type { Theme } from '@mui/material/styles'; diff --git a/packages/manager/src/components/MainContentBanner.test.tsx b/packages/manager/src/components/MainContentBanner.test.tsx index bd068c15b1b..f5531e4e2d0 100644 --- a/packages/manager/src/components/MainContentBanner.test.tsx +++ b/packages/manager/src/components/MainContentBanner.test.tsx @@ -7,7 +7,7 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { MainContentBanner } from './MainContentBanner'; -import type { ManagerPreferences } from 'src/types/ManagerPreferences'; +import type { ManagerPreferences } from '@linode/utilities'; describe('MainContentBanner', () => { const mainContentBanner = { @@ -37,7 +37,7 @@ describe('MainContentBanner', () => { expect(link).toBeVisible(); expect(link).toBeEnabled(); expect(link).toHaveRole('link'); - expect(link).toHaveAttribute('href', 'https://akamai.com'); + expect(link).toHaveAttribute('href', 'https://akamai.com/'); }); it('should be dismissable', async () => { diff --git a/packages/manager/src/components/MainContentBanner.tsx b/packages/manager/src/components/MainContentBanner.tsx index 239389e033c..ca5d787a2c2 100644 --- a/packages/manager/src/components/MainContentBanner.tsx +++ b/packages/manager/src/components/MainContentBanner.tsx @@ -5,10 +5,7 @@ import * as React from 'react'; import { Link } from 'src/components/Link'; import { useFlags } from 'src/hooks/useFlags'; -import { - useMutatePreferences, - usePreferences, -} from 'src/queries/profile/preferences'; +import { useMutatePreferences, usePreferences } from '@linode/queries'; export const MainContentBanner = React.memo(() => { // Uncomment this to test this banner: diff --git a/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx b/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx index 402536b26f5..1463ab86584 100644 --- a/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx +++ b/packages/manager/src/components/MaintenanceBanner/MaintenanceBanner.tsx @@ -1,10 +1,9 @@ +import { useAllAccountMaintenanceQuery, useProfile } from '@linode/queries'; import { Notice, Typography } from '@linode/ui'; import * as React from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from 'src/components/Link'; import { PENDING_MAINTENANCE_FILTER } from 'src/features/Account/Maintenance/utilities'; -import { useAllAccountMaintenanceQuery } from 'src/queries/account/maintenance'; -import { useProfile } from 'src/queries/profile/profile'; import { formatDate } from 'src/utilities/formatDate'; import { isPast } from 'src/utilities/isPast'; diff --git a/packages/manager/src/components/MaintenanceScreen.tsx b/packages/manager/src/components/MaintenanceScreen.tsx index 00d1f5abb78..c0cdd1cb4d2 100644 --- a/packages/manager/src/components/MaintenanceScreen.tsx +++ b/packages/manager/src/components/MaintenanceScreen.tsx @@ -1,10 +1,9 @@ -import { Box, Stack, Typography } from '@linode/ui'; +import { Box, ErrorState, Stack, Typography } from '@linode/ui'; import BuildIcon from '@mui/icons-material/Build'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import Logo from 'src/assets/logo/akamai-logo.svg'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Link } from 'src/components/Link'; import type { Theme } from '@mui/material/styles'; diff --git a/packages/manager/src/components/Markdown/__snapshots__/Markdown.test.tsx.snap b/packages/manager/src/components/Markdown/__snapshots__/Markdown.test.tsx.snap index 2a757ad60ba..3b2c384c680 100644 --- a/packages/manager/src/components/Markdown/__snapshots__/Markdown.test.tsx.snap +++ b/packages/manager/src/components/Markdown/__snapshots__/Markdown.test.tsx.snap @@ -3,7 +3,7 @@ exports[`Markdown component > should highlight text consistently 1`] = `

Some markdown diff --git a/packages/manager/src/components/MaskableText/MaskableText.test.tsx b/packages/manager/src/components/MaskableText/MaskableText.test.tsx index 38c101a9230..6bd0d5c999b 100644 --- a/packages/manager/src/components/MaskableText/MaskableText.test.tsx +++ b/packages/manager/src/components/MaskableText/MaskableText.test.tsx @@ -6,7 +6,7 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { MaskableText } from './MaskableText'; import type { MaskableTextProps } from './MaskableText'; -import type { ManagerPreferences } from 'src/types/ManagerPreferences'; +import type { ManagerPreferences } from '@linode/utilities'; describe('MaskableText', () => { const maskedText = '•••••••••••'; @@ -24,8 +24,8 @@ describe('MaskableText', () => { usePreferences: vi.fn().mockReturnValue({}), })); - vi.mock('src/queries/profile/preferences', async () => { - const actual = await vi.importActual('src/queries/profile/preferences'); + vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); return { ...actual, usePreferences: queryMocks.usePreferences, diff --git a/packages/manager/src/components/MaskableText/MaskableText.tsx b/packages/manager/src/components/MaskableText/MaskableText.tsx index 0512d8ef9bb..06c8edadcd6 100644 --- a/packages/manager/src/components/MaskableText/MaskableText.tsx +++ b/packages/manager/src/components/MaskableText/MaskableText.tsx @@ -1,8 +1,7 @@ -import { Stack, VisibilityTooltip } from '@linode/ui'; -import { Typography } from '@linode/ui'; +import { Stack, VisibilityTooltip, Typography } from '@linode/ui'; import * as React from 'react'; -import { usePreferences } from 'src/queries/profile/preferences'; +import { usePreferences } from '@linode/queries'; import { createMaskedText } from 'src/utilities/createMaskedText'; import type { SxProps, Theme } from '@mui/material'; diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx index 0a3f717a970..ee1489870ff 100644 --- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx @@ -7,7 +7,7 @@ import { Typography, } from '@linode/ui'; import Close from '@mui/icons-material/Close'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -252,12 +252,14 @@ export const MultipleIPInput = React.memo((props: MultipeIPInputProps) => { container data-testid="domain-transfer-input" direction="row" - justifyContent="center" key={`domain-transfer-ip-${idx}`} - maxWidth={forVPCIPv4Ranges ? '415px' : undefined} spacing={2} + sx={{ + justifyContent: 'center', + maxWidth: forVPCIPv4Ranges ? '415px' : undefined, + }} > - + { {/** Don't show the button for the first input since it won't do anything, unless this component is * used in DBaaS or for Linode VPC interfaces */} - + {(idx > 0 || forDatabaseAccessControls || forVPCIPv4Ranges) && (

({ + marginTop: theme.spacing(2), + minWidth: theme.breakpoints.values.sm, + })} + > + + + Quota Name + Account Quota Value + Usage + + + + + {hasSelectedLocation && isFetchingQuotas ? ( + + ) : !selectedLocation ? ( + + ) : quotasWithUsage.length === 0 ? ( + + ) : ( + quotasWithUsage.map((quota, index) => { + const hasQuotaUsage = quota.usage?.used !== null; + + return ( + + ); + }) + )} + +
+ {selectedLocation && !isFetchingQuotas && ( + + )} + + setSupportModalOpen(false)} + open={supportModalOpen} + title="Increase Quota" + > + {selectedQuota && ( + setSupportModalOpen(false)} + onSuccess={onIncreaseQuotaTicketCreated} + open={supportModalOpen} + quota={selectedQuota} + /> + )} + + + ); +}; diff --git a/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx b/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx new file mode 100644 index 00000000000..fae05e6d098 --- /dev/null +++ b/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx @@ -0,0 +1,150 @@ +import { Box, CircleProgress, TooltipIcon, Typography } from '@linode/ui'; +import ErrorOutline from '@mui/icons-material/ErrorOutline'; +import { useTheme } from '@mui/material/styles'; +import * as React from 'react'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { BarPercent } from 'src/components/BarPercent/BarPercent'; +import { TableCell } from 'src/components/TableCell/TableCell'; +import { TableRow } from 'src/components/TableRow/TableRow'; +import { useFlags } from 'src/hooks/useFlags'; +import { useIsAkamaiAccount } from 'src/hooks/useIsAkamaiAccount'; + +import { getQuotaError } from './utils'; + +import type { Quota, QuotaUsage } from '@linode/api-v4'; +import type { UseQueryResult } from '@tanstack/react-query'; +import type { Action } from 'src/components/ActionMenu/ActionMenu'; + +interface QuotaWithUsage extends Quota { + usage?: QuotaUsage; +} + +interface QuotasTableRowProps { + hasQuotaUsage: boolean; + index: number; + quota: QuotaWithUsage; + quotaUsageQueries: UseQueryResult[]; + setSelectedQuota: (quota: Quota) => void; + setSupportModalOpen: (open: boolean) => void; +} + +const quotaRowMinHeight = 58; + +export const QuotasTableRow = (props: QuotasTableRowProps) => { + const { + hasQuotaUsage, + index, + quota, + quotaUsageQueries, + setSelectedQuota, + setSupportModalOpen, + } = props; + const theme = useTheme(); + const flags = useFlags(); + const { isAkamaiAccount } = useIsAkamaiAccount(); + // These conditions are meant to achieve a couple things: + // 1. Ability to disable the request for increase button for Internal accounts (this will be used for early adopters, and removed eventually). + // 2. Ability to disable the request for increase button for All accounts (this is a prevention measure when beta is in GA). + const isRequestForQuotaButtonDisabled = + flags.limitsEvolution?.requestForIncreaseDisabledForAll || + (flags.limitsEvolution?.requestForIncreaseDisabledForInternalAccountsOnly && + isAkamaiAccount); + + const requestIncreaseAction: Action = { + disabled: isRequestForQuotaButtonDisabled, + onClick: () => { + setSelectedQuota(quota); + setSupportModalOpen(true); + }, + title: 'Request an Increase', + }; + + return ( + + + + + {quota.quota_name} + + + + + {quota.quota_limit} + + + {quotaUsageQueries[index]?.isLoading ? ( + + {' '} + Fetching Data... + + ) : quotaUsageQueries[index]?.error ? ( + + + {getQuotaError(quotaUsageQueries, index)} + + ) : hasQuotaUsage ? ( + <> + + + {`${quota.usage?.used} of ${quota.quota_limit} ${ + quota.resource_metric + }${quota.quota_limit > 1 ? 's' : ''} used`} + + + ) : ( + Data not available + )} + + + {hasQuotaUsage ? ( + + + + ) : ( + + )} + + ); +}; diff --git a/packages/manager/src/features/Account/Quotas/utils.test.tsx b/packages/manager/src/features/Account/Quotas/utils.test.tsx new file mode 100644 index 00000000000..1defc2fd551 --- /dev/null +++ b/packages/manager/src/features/Account/Quotas/utils.test.tsx @@ -0,0 +1,122 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react'; +import * as React from 'react'; + +import { profileFactory } from 'src/factories/profile'; +import { quotaFactory, quotaUsageFactory } from 'src/factories/quotas'; + +import { + getQuotaError, + getQuotaIncreaseMessage, + useGetLocationsForQuotaService, +} from './utils'; + +import type { QuotaUsage } from '@linode/api-v4'; +import type { UseQueryResult } from '@tanstack/react-query'; + +const queryMocks = vi.hoisted(() => ({ + useObjectStorageEndpoints: vi.fn().mockReturnValue({}), +})); + +vi.mock('src/queries/object-storage/queries', () => { + const actual = vi.importActual('src/queries/object-storage/queries'); + return { + ...actual, + useObjectStorageEndpoints: queryMocks.useObjectStorageEndpoints, + }; +}); + +describe('useGetLocationsForQuotaService', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return ( + {children} + ); + }; + + it('should handle object storage endpoints with null values', () => { + const { result } = renderHook( + () => useGetLocationsForQuotaService('object-storage'), + { + wrapper, + } + ); + + expect(result.current.s3Endpoints).toEqual([ + { label: 'Global (Account level)', value: 'global' }, + ]); + }); + + it('should filter out endpoints with null s3_endpoint values', () => { + queryMocks.useObjectStorageEndpoints.mockReturnValue({ + data: [ + { + endpoint_type: 'E0', + s3_endpoint: 'endpoint1', + }, + { + endpoint_type: 'E0', + s3_endpoint: null, + }, + ], + }); + + const { result } = renderHook( + () => useGetLocationsForQuotaService('object-storage'), + { + wrapper, + } + ); + + expect(result.current.s3Endpoints).toEqual([ + { label: 'Global (Account level)', value: 'global' }, + { label: 'endpoint1 (Standard E0)', value: 'endpoint1' }, + ]); + }); + + it('should return the error for a given quota usage query', () => { + const quotaUsageQueries = ([ + { error: [{ reason: 'Error 1' }] }, + { error: [{ reason: 'Error 2' }] }, + ] as unknown) as UseQueryResult[]; + const index = 0; + + const error = getQuotaError(quotaUsageQueries, index); + + expect(error).toEqual('Error 1'); + }); + + it('getQuotaIncreaseFormDefaultValues should return the correct default values', () => { + const profile = profileFactory.build(); + const baseQuota = quotaFactory.build(); + const quotaUsage = quotaUsageFactory.build(); + const quota = { + ...baseQuota, + ...quotaUsage, + }; + const quantity = 1; + + const defaultValues = getQuotaIncreaseMessage({ + profile, + quantity, + quota, + }); + + expect(defaultValues.description).toEqual( + `**User**: ${profile.username}
\n**Email**: ${ + profile.email + }
\n**Quota Name**: ${ + quota.quota_name + }
\n**New Quantity Requested**: ${quantity} ${quota.resource_metric}${ + quantity > 1 ? 's' : '' + }
\n**Region**: ${quota.region_applied}` + ); + }); +}); diff --git a/packages/manager/src/features/Account/Quotas/utils.ts b/packages/manager/src/features/Account/Quotas/utils.ts new file mode 100644 index 00000000000..29c9c97d993 --- /dev/null +++ b/packages/manager/src/features/Account/Quotas/utils.ts @@ -0,0 +1,166 @@ +import { useRegionsQuery } from '@linode/queries'; +import { object, string } from 'yup'; + +import { + GLOBAL_QUOTA_LABEL, + GLOBAL_QUOTA_VALUE, + regionSelectGlobalOption, +} from 'src/components/RegionSelect/constants'; +import { useObjectStorageEndpoints } from 'src/queries/object-storage/queries'; + +import type { QuotaIncreaseFormFields } from './QuotasIncreaseForm'; +import type { + Filter, + Profile, + Quota, + QuotaType, + QuotaUsage, + Region, +} from '@linode/api-v4'; +import type { SelectOption } from '@linode/ui'; +import type { UseQueryResult } from '@tanstack/react-query'; + +type UseGetLocationsForQuotaService = + | { + isFetchingRegions: boolean; + regions: Region[]; + s3Endpoints: null; + service: Exclude; + } + | { + isFetchingS3Endpoints: boolean; + regions: null; + s3Endpoints: { label: string; value: string }[]; + service: Extract; + }; + +/** + * Function to get either: + * - The region(s) for a given quota service (linode, lke, ...) + * - The s3 endpoint(s) (object-storage) + */ +export const useGetLocationsForQuotaService = ( + service: QuotaType +): UseGetLocationsForQuotaService => { + const { data: regions, isFetching: isFetchingRegions } = useRegionsQuery(); + // In order to get the s3 endpoints, we need to query the object storage service + // It will only show quotas for assigned endpoints (endpoints relevant to a region a user ever created a resource in). + const { + data: s3Endpoints, + isFetching: isFetchingS3Endpoints, + } = useObjectStorageEndpoints(service === 'object-storage'); + + if (service === 'object-storage') { + return { + isFetchingS3Endpoints, + regions: null, + s3Endpoints: [ + ...[{ label: GLOBAL_QUOTA_LABEL, value: GLOBAL_QUOTA_VALUE }], + ...(s3Endpoints ?? []) + ?.map((s3Endpoint) => { + if (!s3Endpoint.s3_endpoint) { + return null; + } + + return { + label: `${s3Endpoint.s3_endpoint} (Standard ${s3Endpoint.endpoint_type})`, + value: s3Endpoint.s3_endpoint, + }; + }) + .filter((item) => item !== null), + ], + service: 'object-storage', + }; + } + + return { + isFetchingRegions, + regions: [regionSelectGlobalOption, ...(regions ?? [])], + s3Endpoints: null, + service, + }; +}; + +interface GetQuotasFiltersProps { + location: SelectOption | null; + service: SelectOption; +} + +/** + * Function to get the filters for the quotas query + */ +export const getQuotasFilters = ({ + location, + service, +}: GetQuotasFiltersProps): Filter => { + return { + region_applied: + service.value !== 'object-storage' ? location?.value : undefined, + s3_endpoint: + service.value === 'object-storage' ? location?.value : undefined, + }; +}; + +/** + * Function to get the error for a given quota usage query + */ +export const getQuotaError = ( + quotaUsageQueries: UseQueryResult[], + index: number +) => { + return Array.isArray(quotaUsageQueries[index].error) && + quotaUsageQueries[index].error[0]?.reason + ? quotaUsageQueries[index].error[0].reason + : 'An unexpected error occurred'; +}; + +interface GetQuotaIncreaseFormDefaultValuesProps { + profile: Profile | undefined; + quantity: number; + quota: Quota; +} + +/** + * Function to get the default values for the quota increase form + */ +export const getQuotaIncreaseMessage = ({ + profile, + quantity, + quota, +}: GetQuotaIncreaseFormDefaultValuesProps): QuotaIncreaseFormFields => { + const regionAppliedLabel = quota.s3_endpoint ? 'Endpoint' : 'Region'; + const regionAppliedValue = quota.s3_endpoint ?? quota.region_applied; + + if (!profile) { + return { + description: '', + notes: '', + quantity: '0', + summary: 'Increase Quota', + }; + } + + return { + description: `**User**: ${profile.username}
\n**Email**: ${ + profile.email + }
\n**Quota Name**: ${ + quota.quota_name + }
\n**New Quantity Requested**: ${quantity} ${quota.resource_metric}${ + quantity > 1 ? 's' : '' + }
\n**${regionAppliedLabel}**: ${regionAppliedValue}`, + notes: '', + quantity: '0', + summary: 'Increase Quota', + }; +}; + +export const getQuotaIncreaseFormSchema = object({ + description: string().required('Description is required.'), + notes: string() + .optional() + .max(255, 'Notes must be less than 255 characters.'), + quantity: string() + .required('Quantity is required') + .matches(/^[1-9]\d*$/, 'Quantity must be a number greater than 0.'), + summary: string().required('Summary is required.'), +}); diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx index c9b32879308..9de17a38eca 100644 --- a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx +++ b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx @@ -1,20 +1,19 @@ -import { Notice, StyledLinkButton, Typography } from '@linode/ui'; +import { Drawer, Notice, StyledLinkButton, Typography } from '@linode/ui'; import React from 'react'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; -import { Drawer } from 'src/components/Drawer'; +import { NotFound } from 'src/components/NotFound'; import { PARENT_USER_SESSION_EXPIRED } from 'src/features/Account/constants'; import { useParentChildAuthentication } from 'src/features/Account/SwitchAccounts/useParentChildAuthentication'; import { setTokenInLocalStorage } from 'src/features/Account/SwitchAccounts/utils'; -import { useCurrentToken } from 'src/hooks/useAuthentication'; import { sendSwitchToParentAccountEvent } from 'src/utilities/analytics/customEventAnalytics'; +import { getAuthToken } from 'src/utilities/authentication'; import { getStorage, setStorage } from 'src/utilities/storage'; import { ChildAccountList } from './SwitchAccounts/ChildAccountList'; import { updateParentTokenInLocalStorage } from './SwitchAccounts/utils'; import type { APIError, UserType } from '@linode/api-v4'; -import type { State as AuthState } from 'src/store/authentication'; interface Props { onClose: () => void; @@ -23,7 +22,7 @@ interface Props { } interface HandleSwitchToChildAccountProps { - currentTokenWithBearer?: AuthState['token']; + currentTokenWithBearer?: string; euuid: string; event: React.MouseEvent; onClose: (e: React.SyntheticEvent) => void; @@ -39,9 +38,9 @@ export const SwitchAccountDrawer = (props: Props) => { const [query, setQuery] = React.useState(''); const isProxyUser = userType === 'proxy'; - const currentParentTokenWithBearer = + const currentParentTokenWithBearer: string = getStorage('authentication/parent_token/token') ?? ''; - const currentTokenWithBearer = useCurrentToken() ?? ''; + const currentTokenWithBearer = getAuthToken().token; const { createToken, @@ -124,7 +123,12 @@ export const SwitchAccountDrawer = (props: Props) => { }, [onClose, revokeToken, validateParentToken, updateCurrentToken]); return ( - + {createTokenErrorReason && ( )} diff --git a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx index ddc14c0a132..0d204a50c2e 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/ChildAccountList.tsx @@ -1,5 +1,5 @@ -import { Typography } from '@linode/ui'; import { + Typography, Box, Button, CircleProgress, @@ -11,7 +11,7 @@ import React, { useState } from 'react'; import { Waypoint } from 'react-waypoint'; import ErrorStateCloud from 'src/assets/icons/error-state-cloud.svg'; -import { useChildAccountsInfiniteQuery } from 'src/queries/account/account'; +import { useChildAccountsInfiniteQuery } from '@linode/queries'; import type { Filter, UserType } from '@linode/api-v4'; diff --git a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx index 9bb00b35195..afbaaf2590f 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx @@ -1,16 +1,14 @@ -import { Typography } from '@linode/ui'; +import { ActionsPanel, Typography } from '@linode/ui'; +import { pluralize, useInterval } from '@linode/utilities'; import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { sessionExpirationContext as _sessionExpirationContext } from 'src/context/sessionExpirationContext'; import { useParentChildAuthentication } from 'src/features/Account/SwitchAccounts/useParentChildAuthentication'; import { setTokenInLocalStorage } from 'src/features/Account/SwitchAccounts/utils'; -import { useInterval } from 'src/hooks/useInterval'; -import { useAccount } from 'src/queries/account/account'; +import { useAccount } from '@linode/queries'; import { parseAPIDate } from 'src/utilities/date'; -import { pluralize } from 'src/utilities/pluralize'; import { getStorage, setStorage } from 'src/utilities/storage'; interface SessionExpirationDialogProps { diff --git a/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.tsx b/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.tsx index 524c1561e9a..5ca66a48388 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/SwitchAccountSessionDialog.tsx @@ -1,8 +1,7 @@ -import { Typography } from '@linode/ui'; +import { ActionsPanel, Typography } from '@linode/ui'; import React from 'react'; import { useHistory } from 'react-router-dom'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { sendSwitchAccountSessionExpiryEvent } from 'src/utilities/analytics/customEventAnalytics'; diff --git a/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx b/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx index 5924c18ec8c..fb8939f393f 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx @@ -2,21 +2,21 @@ import { deletePersonalAccessToken, getPersonalAccessTokens, } from '@linode/api-v4'; +import { useCreateChildAccountPersonalAccessTokenMutation } from '@linode/queries'; import { useCallback } from 'react'; -import { getPersonalAccessTokenForRevocation } from 'src/features/Account/SwitchAccounts/utils'; import { + getPersonalAccessTokenForRevocation, isParentTokenValid, updateCurrentTokenBasedOnUserType, } from 'src/features/Account/SwitchAccounts/utils'; -import { useCurrentToken } from 'src/hooks/useAuthentication'; -import { useCreateChildAccountPersonalAccessTokenMutation } from 'src/queries/account/account'; +import { getAuthToken } from 'src/utilities/authentication'; import { getStorage } from 'src/utilities/storage'; import type { Token, UserType } from '@linode/api-v4'; export const useParentChildAuthentication = () => { - const currentTokenWithBearer = useCurrentToken() ?? ''; + const currentTokenWithBearer = getAuthToken().token; const { error: createTokenError, diff --git a/packages/manager/src/features/Account/SwitchAccounts/utils.ts b/packages/manager/src/features/Account/SwitchAccounts/utils.ts index 7484f44042c..8775d74c568 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/utils.ts +++ b/packages/manager/src/features/Account/SwitchAccounts/utils.ts @@ -1,7 +1,6 @@ import { getStorage, setStorage } from 'src/utilities/storage'; import type { Token, UserType } from '@linode/api-v4'; -import type { State as AuthState } from 'src/store/authentication'; export interface ProxyTokenCreationParams { /** @@ -21,7 +20,7 @@ export interface ProxyTokenCreationParams { export const updateParentTokenInLocalStorage = ({ currentTokenWithBearer, }: { - currentTokenWithBearer?: AuthState['token']; + currentTokenWithBearer?: string; }) => { const parentToken: Token = { created: getStorage('authentication/created'), diff --git a/packages/manager/src/features/Account/constants.ts b/packages/manager/src/features/Account/constants.ts index 992e72762e5..575c0ccf20c 100644 --- a/packages/manager/src/features/Account/constants.ts +++ b/packages/manager/src/features/Account/constants.ts @@ -4,6 +4,7 @@ export const CUSTOMER_SUPPORT = 'customer support'; export const grantTypeMap = { account: 'Account', + bucket: 'Buckets', database: 'Databases', domain: 'Domains', firewall: 'Firewalls', diff --git a/packages/manager/src/features/Backups/AutoEnroll.tsx b/packages/manager/src/features/Backups/AutoEnroll.tsx index 5a1fcb0453c..d898234d868 100644 --- a/packages/manager/src/features/Backups/AutoEnroll.tsx +++ b/packages/manager/src/features/Backups/AutoEnroll.tsx @@ -2,11 +2,11 @@ import { FormControlLabel, Notice, Paper, + Stack, Toggle, Typography, } from '@linode/ui'; -import { styled } from '@mui/material/styles'; -import * as React from 'react'; +import React from 'react'; import { Link } from 'src/components/Link'; @@ -20,58 +20,35 @@ export const AutoEnroll = (props: AutoEnrollProps) => { const { enabled, error, toggle } = props; return ( - + ({ backgroundColor: theme.palette.background.default })} + variant="outlined" + > {error && } - - + + ({ font: theme.font.bold })}> Auto Enroll All New Linodes in Backups - + Enroll all future Linodes in backups. Your account will be billed the additional hourly rate noted on the{' '} Backups pricing page . - + } - control={} + checked={enabled} + control={} + onChange={toggle} + sx={{ gap: 1 }} /> - +
); }; - -const StyledPaper = styled(Paper, { - label: 'StyledPaper', -})(({ theme }) => ({ - backgroundColor: theme.bg.offWhite, - padding: theme.spacing(1), -})); - -const StyledFormControlLabel = styled(FormControlLabel, { - label: 'StyledFormControlLabel', -})(({ theme }) => ({ - alignItems: 'flex-start', - display: 'flex', - marginBottom: theme.spacing(1), - marginLeft: 0, -})); - -const StyledDiv = styled('div', { - label: 'StyledDiv', -})(({ theme }) => ({ - marginTop: theme.spacing(1.5), -})); - -const StyledTypography = styled(Typography, { - label: 'StyledTypography', -})(({ theme }) => ({ - fontSize: 17, - marginBottom: theme.spacing(1), -})); diff --git a/packages/manager/src/features/Backups/BackupDrawer.test.tsx b/packages/manager/src/features/Backups/BackupDrawer.test.tsx index 26eeecebc10..be8bdcde0f7 100644 --- a/packages/manager/src/features/Backups/BackupDrawer.test.tsx +++ b/packages/manager/src/features/Backups/BackupDrawer.test.tsx @@ -23,8 +23,8 @@ const queryMocks = vi.hoisted(() => ({ }), })); -vi.mock('src/queries/linodes/linodes', async () => { - const actual = await vi.importActual('src/queries/linodes/linodes'); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); return { ...actual, useAllLinodesQuery: queryMocks.useAllLinodesQuery, diff --git a/packages/manager/src/features/Backups/BackupDrawer.tsx b/packages/manager/src/features/Backups/BackupDrawer.tsx index 7e8dfefa222..fe4e2eb313c 100644 --- a/packages/manager/src/features/Backups/BackupDrawer.tsx +++ b/packages/manager/src/features/Backups/BackupDrawer.tsx @@ -1,12 +1,24 @@ -import { Box, Notice, Stack, Typography } from '@linode/ui'; +import { + useAccountSettings, + useAllLinodesQuery, + useMutateAccountSettings, +} from '@linode/queries'; +import { + ActionsPanel, + Box, + Drawer, + Notice, + Stack, + Typography, +} from '@linode/ui'; +import { isNumber, pluralize } from '@linode/utilities'; import { styled } from '@mui/material'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { DisplayPrice } from 'src/components/DisplayPrice'; -import { Drawer } from 'src/components/Drawer'; import { Link } from 'src/components/Link'; +import { NotFound } from 'src/components/NotFound'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; @@ -14,14 +26,7 @@ import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; -import { - useAccountSettings, - useMutateAccountSettings, -} from 'src/queries/account/settings'; -import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { useAllTypes } from 'src/queries/types'; -import { isNumber } from 'src/utilities/isNumber'; -import { pluralize } from 'src/utilities/pluralize'; import { getTotalBackupsPrice } from 'src/utilities/pricing/backups'; import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; @@ -87,7 +92,7 @@ export const BackupDrawer = (props: Props) => { const renderBackupsTable = () => { if (linodesLoading || typesLoading || accountSettingsLoading) { - return ; + return ; } if (linodesError) { return ; @@ -143,7 +148,13 @@ all new Linodes will automatically be backed up.` }); return ( - + Three backup slots are executed and rotated automatically: a daily diff --git a/packages/manager/src/features/Backups/BackupLinodeRow.tsx b/packages/manager/src/features/Backups/BackupLinodeRow.tsx index 9c47812e7c9..98f229c2efa 100644 --- a/packages/manager/src/features/Backups/BackupLinodeRow.tsx +++ b/packages/manager/src/features/Backups/BackupLinodeRow.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { useRegionsQuery } from 'src/queries/regions/regions'; +import { useRegionsQuery } from '@linode/queries'; import { useTypeQuery } from 'src/queries/types'; import { getMonthlyBackupsPrice } from 'src/utilities/pricing/backups'; import { diff --git a/packages/manager/src/features/Backups/BackupsCTA.tsx b/packages/manager/src/features/Backups/BackupsCTA.tsx index 5aa84cc5aa8..ed964b91a34 100644 --- a/packages/manager/src/features/Backups/BackupsCTA.tsx +++ b/packages/manager/src/features/Backups/BackupsCTA.tsx @@ -3,13 +3,13 @@ import Close from '@mui/icons-material/Close'; import React from 'react'; import { LinkButton } from 'src/components/LinkButton'; -import { useAccountSettings } from 'src/queries/account/settings'; -import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { + useAccountSettings, + useAllLinodesQuery, useMutatePreferences, usePreferences, -} from 'src/queries/profile/preferences'; -import { useProfile } from 'src/queries/profile/profile'; + useProfile, +} from '@linode/queries'; import { BackupDrawer } from './BackupDrawer'; diff --git a/packages/manager/src/features/Backups/utils.ts b/packages/manager/src/features/Backups/utils.ts index 85193d82625..e91d11de260 100644 --- a/packages/manager/src/features/Backups/utils.ts +++ b/packages/manager/src/features/Backups/utils.ts @@ -1,9 +1,8 @@ import { enableBackups } from '@linode/api-v4'; +import { linodeQueries } from '@linode/queries'; +import { pluralize } from '@linode/utilities'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { linodeQueries } from 'src/queries/linodes/linodes'; -import { pluralize } from 'src/utilities/pluralize'; - import type { APIError, Linode } from '@linode/api-v4'; interface EnableBackupsFufilledResult extends PromiseFulfilledResult<{}> { diff --git a/packages/manager/src/features/Betas/BetaDetailsList.tsx b/packages/manager/src/features/Betas/BetaDetailsList.tsx index 3b209df9705..afea748e3a2 100644 --- a/packages/manager/src/features/Betas/BetaDetailsList.tsx +++ b/packages/manager/src/features/Betas/BetaDetailsList.tsx @@ -1,8 +1,13 @@ -import { CircleProgress, Divider, Paper, Stack, Typography } from '@linode/ui'; +import { + CircleProgress, + Divider, + ErrorState, + Paper, + Stack, + Typography, +} from '@linode/ui'; import * as React from 'react'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; - import BetaDetails from './BetaDetails'; import type { APIError } from '@linode/api-v4'; diff --git a/packages/manager/src/features/Betas/BetaSignup.tsx b/packages/manager/src/features/Betas/BetaSignup.tsx index 01e72251575..bd54fcc62bb 100644 --- a/packages/manager/src/features/Betas/BetaSignup.tsx +++ b/packages/manager/src/features/Betas/BetaSignup.tsx @@ -1,4 +1,11 @@ -import { Checkbox, CircleProgress, Paper, Stack, Typography } from '@linode/ui'; +import { + ActionsPanel, + Checkbox, + CircleProgress, + Paper, + Stack, + Typography, +} from '@linode/ui'; import { createLazyRoute, useNavigate, @@ -7,11 +14,10 @@ import { import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { LandingHeader } from 'src/components/LandingHeader/LandingHeader'; import { Markdown } from 'src/components/Markdown/Markdown'; import { NotFound } from 'src/components/NotFound'; -import { useCreateAccountBetaMutation } from 'src/queries/account/betas'; +import { useCreateAccountBetaMutation } from '@linode/queries'; import { useBetaQuery } from 'src/queries/betas'; export const BetaSignup = () => { diff --git a/packages/manager/src/features/Betas/BetasLanding.tsx b/packages/manager/src/features/Betas/BetasLanding.tsx index 62de9908a22..028fc7dbf8e 100644 --- a/packages/manager/src/features/Betas/BetasLanding.tsx +++ b/packages/manager/src/features/Betas/BetasLanding.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { LandingHeader } from 'src/components/LandingHeader/LandingHeader'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { BetaDetailsList } from 'src/features/Betas/BetaDetailsList'; -import { useAccountBetasQuery } from 'src/queries/account/betas'; +import { useAccountBetasQuery } from '@linode/queries'; import { useBetasQuery } from 'src/queries/betas'; import { categorizeBetasByStatus } from 'src/utilities/betaUtils'; diff --git a/packages/manager/src/features/Billing/BillingDetail.tsx b/packages/manager/src/features/Billing/BillingDetail.tsx index df85a4e5040..138b31e6ca7 100644 --- a/packages/manager/src/features/Billing/BillingDetail.tsx +++ b/packages/manager/src/features/Billing/BillingDetail.tsx @@ -1,16 +1,17 @@ -import { Button, CircleProgress } from '@linode/ui'; +import { Button, CircleProgress, ErrorState } from '@linode/ui'; import Paper from '@mui/material/Paper'; import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { PayPalScriptProvider } from '@paypal/react-paypal-js'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { PAYPAL_CLIENT_ID } from 'src/constants'; -import { useAccount } from 'src/queries/account/account'; -import { useAllPaymentMethodsQuery } from 'src/queries/account/payment'; -import { useProfile } from 'src/queries/profile/profile'; +import { + useAccount, + useAllPaymentMethodsQuery, + useProfile, +} from '@linode/queries'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { BillingActivityPanel } from './BillingPanels/BillingActivityPanel/BillingActivityPanel'; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx index b1cd0138ae2..ff13ce46c09 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx @@ -1,8 +1,16 @@ import { getInvoiceItems } from '@linode/api-v4/lib/account'; +import { + useAccount, + useAllAccountInvoices, + useAllAccountPayments, + useProfile, + useRegionsQuery, +} from '@linode/queries'; import { Autocomplete, Typography } from '@linode/ui'; +import { getAll, useSet } from '@linode/utilities'; +import Grid from '@mui/material/Grid2'; import Paper from '@mui/material/Paper'; import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; import { DateTime } from 'luxon'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -32,17 +40,8 @@ import { import { useFlags } from 'src/hooks/useFlags'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; -import { useSet } from 'src/hooks/useSet'; -import { useAccount } from 'src/queries/account/account'; -import { - useAllAccountInvoices, - useAllAccountPayments, -} from 'src/queries/account/billing'; -import { useProfile } from 'src/queries/profile/profile'; -import { useRegionsQuery } from 'src/queries/regions/regions'; import { parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; -import { getAll } from 'src/utilities/getAll'; import { getTaxID } from '../../billingUtils'; @@ -385,7 +384,7 @@ export const BillingActivityPanel = React.memo((props: Props) => { }; return ( - + { }; // The layout changes if there are promotions. - const gridDimensions: Partial> = + const gridDimensions = promotions && promotions.length > 0 ? { md: 4, xs: 12 } : { sm: 6, xs: 12 }; const balanceJSX = @@ -156,8 +153,20 @@ export const BillingSummary = (props: BillingSummaryProps) => { return ( <> - - + + Account Balance @@ -202,7 +211,13 @@ export const BillingSummary = (props: BillingSummaryProps) => { {promotions && promotions?.length > 0 ? ( - + Promotions @@ -218,7 +233,7 @@ export const BillingSummary = (props: BillingSummaryProps) => { ) : null} - + Accrued Charges diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx index d95feb817ad..9e76162ff88 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/GooglePayButton.tsx @@ -1,5 +1,7 @@ +import { useAccount, useClientToken } from '@linode/queries'; import { CircleProgress, Tooltip } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import { useScript } from '@linode/utilities'; +import Grid from '@mui/material/Grid2'; import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -10,9 +12,6 @@ import { gPay, initGooglePaymentInstance, } from 'src/features/Billing/GooglePayProvider'; -import { useScript } from 'src/hooks/useScript'; -import { useAccount } from 'src/queries/account/account'; -import { useClientToken } from 'src/queries/account/payment'; import type { SetSuccess } from './types'; import type { APIWarning } from '@linode/api-v4/lib/types'; @@ -146,10 +145,12 @@ export const GooglePayButton = (props: Props) => { if (isLoading) { return ( diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx index 9af3f088734..c74bca1d5fa 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PayPalButton.tsx @@ -1,6 +1,6 @@ import { makePayment } from '@linode/api-v4/lib/account/payments'; import { CircleProgress, Tooltip } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { BraintreePayPalButtons, FUNDING, @@ -12,9 +12,7 @@ import { makeStyles } from 'tss-react/mui'; import { reportException } from 'src/exceptionReporting'; import { getPaymentLimits } from 'src/features/Billing/billingUtils'; -import { useAccount } from 'src/queries/account/account'; -import { useClientToken } from 'src/queries/account/payment'; -import { accountQueries } from 'src/queries/account/queries'; +import { useAccount, useClientToken, accountQueries } from '@linode/queries'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import type { SetSuccess } from './types'; @@ -220,10 +218,12 @@ export const PayPalButton = (props: Props) => { if (clientTokenLoading || isPending || !options['data-client-token']) { return ( diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentBits/CreditCardDialog.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentBits/CreditCardDialog.tsx index bd28c8bb7da..3152be8c4df 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentBits/CreditCardDialog.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentBits/CreditCardDialog.tsx @@ -1,7 +1,6 @@ -import { Typography } from '@linode/ui'; +import { ActionsPanel, Typography } from '@linode/ui'; import * as React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; interface Actions { diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.test.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.test.tsx index 0133dffcccf..d8c144f7f16 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.test.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.test.tsx @@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { paymentFactory } from 'src/factories/billing'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { wrapWithTheme } from 'src/utilities/testHelpers'; import PaymentDrawer, { getMinimumPayment } from './PaymentDrawer'; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx index 8cd4021fa94..9ebed4c1dcd 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx @@ -1,30 +1,29 @@ import { makePayment } from '@linode/api-v4/lib/account'; -import { Typography } from '@linode/ui'; +import { accountQueries, useAccount, useProfile } from '@linode/queries'; import { Button, Divider, + Drawer, + ErrorState, InputAdornment, Notice, Stack, TextField, TooltipIcon, + Typography, } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { Currency } from 'src/components/Currency'; -import { Drawer } from 'src/components/Drawer'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LinearProgress } from 'src/components/LinearProgress'; +import { NotFound } from 'src/components/NotFound'; import { SupportLink } from 'src/components/SupportLink'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import { useAccount } from 'src/queries/account/account'; -import { accountQueries } from 'src/queries/account/queries'; -import { useProfile } from 'src/queries/profile/profile'; import { isCreditCardExpired } from 'src/utilities/creditCard'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -239,7 +238,12 @@ export const PaymentDrawer = (props: Props) => { } return ( - + {isReadOnly && ( { - + { /> - + { const renderVariant = () => { return is_default ? ( - + ) : null; }; return ( - + { - const actual = await vi.importActual('src/queries/profile/profile'); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); return { ...actual, useGrants: queryMocks.useGrants, diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx index 2c85cd0b195..123bf8f2de8 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/ContactInformation.tsx @@ -1,5 +1,5 @@ import { Box, TooltipIcon, Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { allCountries } from 'country-region-data'; import * as React from 'react'; import { useState } from 'react'; @@ -10,8 +10,7 @@ import { getRestrictedResourceText } from 'src/features/Account/utils'; import { EDIT_BILLING_CONTACT } from 'src/features/Billing/constants'; import { StyledAutorenewIcon } from 'src/features/TopMenu/NotificationMenu/NotificationMenu'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import { useNotificationsQuery } from 'src/queries/account/notifications'; -import { usePreferences } from 'src/queries/profile/preferences'; +import { useNotificationsQuery, usePreferences } from '@linode/queries'; import { BillingActionButton, @@ -153,7 +152,12 @@ export const ContactInformation = React.memo((props: Props) => { }; return ( - + Billing Contact diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/EditBillingContactDrawer.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/EditBillingContactDrawer.tsx index fd93ea66293..65537fc3e3d 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/EditBillingContactDrawer.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/EditBillingContactDrawer.tsx @@ -1,7 +1,8 @@ +import { Drawer } from '@linode/ui'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { Drawer } from 'src/components/Drawer'; +import { NotFound } from 'src/components/NotFound'; import UpdateContactInformationForm from './UpdateContactInformationForm'; @@ -29,6 +30,7 @@ export const BillingContactDrawer = (props: Props) => { return ( { const { data: account } = useAccount(); const { error, isPending, mutateAsync } = useMutateAccount(); + const queryClient = useQueryClient(); const { data: notifications, refetch } = useNotificationsQuery(); const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); const { classes } = useStyles(); @@ -81,7 +89,38 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { delete clonedValues.company; } - await mutateAsync(clonedValues); + await mutateAsync(clonedValues, { + onSuccess: (account) => { + queryClient.setQueryData( + accountQueries.account.queryKey, + (prevAccount) => { + if (!prevAccount) { + return account; + } + + if ( + isTaxIdEnabled && + account.tax_id && + account.country !== 'US' && + prevAccount?.tax_id !== account.tax_id + ) { + enqueueSnackbar( + "You edited the Tax Identification Number. It's being verified. You'll get an email with the verification result.", + { + hideIconVariant: false, + variant: 'info', + } + ); + queryClient.invalidateQueries({ + queryKey: accountQueries.notifications.queryKey, + }); + } + + return account; + } + ); + }, + }); if (billingAgreementChecked) { try { @@ -223,7 +262,7 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { spacing={0} > {isReadOnly && ( - + { )} {generalError && ( - + )} - + { value={formik.values.email} /> - + { value={formik.values.first_name} /> - + { value={formik.values.last_name} /> - + { value={formik.values.company} /> - + { value={formik.values.address_1} /> - + { /> - + { placeholder="Select a Country" /> - + {formik.values.country === 'US' || formik.values.country == 'CA' ? ( @@ -372,7 +431,12 @@ const UpdateContactInformationForm = ({ focusEmail, onClose }: Props) => { /> )} - + { value={formik.values.city} /> - + { value={formik.values.zip} /> - + { value={formik.values.phone} /> - + { {nonUSCountry && ( theme.tokens.spacing[60]} - xs={12} + sx={{ + alignItems: 'flex-start', + display: 'flex', + marginTop: (theme) => theme.tokens.spacing.S16, + }} + size={12} > setBillingAgreementChecked(!billingAgreementChecked) } sx={(theme) => ({ - marginRight: theme.tokens.spacing[40], + marginRight: theme.tokens.spacing.S8, padding: 0, })} checked={billingAgreementChecked} diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/AddPaymentMethodDrawer/AddCreditCardForm.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/AddPaymentMethodDrawer/AddCreditCardForm.tsx index 465c01d0380..014869786d3 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/AddPaymentMethodDrawer/AddCreditCardForm.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/AddPaymentMethodDrawer/AddCreditCardForm.tsx @@ -1,7 +1,8 @@ import { addPaymentMethod } from '@linode/api-v4/lib'; -import { Notice, TextField } from '@linode/ui'; +import { accountQueries } from '@linode/queries'; +import { ActionsPanel, Notice, TextField } from '@linode/ui'; import { CreditCardSchema } from '@linode/validation'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { useQueryClient } from '@tanstack/react-query'; import { useFormik, yupToFormErrors } from 'formik'; import { useSnackbar } from 'notistack'; @@ -9,8 +10,6 @@ import * as React from 'react'; import NumberFormat from 'react-number-format'; import { makeStyles } from 'tss-react/mui'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { accountQueries } from 'src/queries/account/queries'; import { parseExpiryYear } from 'src/utilities/creditCard'; import { handleAPIErrors } from 'src/utilities/formikErrorUtils'; @@ -165,12 +164,12 @@ const AddCreditCardForm = (props: Props) => { return (
{error && ( - + )} - + { value={values.card_number} /> - + { placeholder="MM/YY" /> - + { const disabled = isProcessing || hasMaxPaymentMethods || isReadOnly; return ( - + {isProcessing ? ( { /> ) : null} {isReadOnly && ( - + { - + Google Pay You’ll be taken to Google Pay to complete sign up. @@ -131,11 +148,15 @@ export const AddPaymentMethodDrawer = (props: Props) => { {!isReadOnly && ( { - + PayPal You’ll be taken to PayPal to complete sign up. @@ -159,11 +185,15 @@ export const AddPaymentMethodDrawer = (props: Props) => { {!isReadOnly && ( ({ useProfile: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/profile/profile', async () => { - const actual = await vi.importActual('src/queries/profile/profile'); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); return { ...actual, useGrants: queryMocks.useGrants, diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx index 607e47c00d5..e58441d5b44 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentInformation.tsx @@ -1,6 +1,6 @@ import { deletePaymentMethod } from '@linode/api-v4/lib/account'; import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; import { useHistory, useRouteMatch } from 'react-router-dom'; @@ -10,7 +10,7 @@ import { getRestrictedResourceText } from 'src/features/Account/utils'; import { PaymentMethods } from 'src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods'; import { ADD_PAYMENT_METHOD } from 'src/features/Billing/constants'; import { useRestrictedGlobalGrantCheck } from 'src/hooks/useRestrictedGlobalGrantCheck'; -import { accountQueries } from 'src/queries/account/queries'; +import { accountQueries } from '@linode/queries'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { @@ -100,7 +100,12 @@ const PaymentInformation = (props: Props) => { }, [addPaymentMethodRouteMatch, openAddDrawer]); return ( - + Payment Methods diff --git a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx index ed243caf2e9..72115b9823b 100644 --- a/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/PaymentInfoPanel/PaymentMethods.tsx @@ -1,5 +1,5 @@ import { CircleProgress, Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { PaymentMethodRow } from 'src/components/PaymentMethodRow/PaymentMethodRow'; diff --git a/packages/manager/src/features/Billing/GooglePayProvider.ts b/packages/manager/src/features/Billing/GooglePayProvider.ts index f840f77c8d5..53ae78768eb 100644 --- a/packages/manager/src/features/Billing/GooglePayProvider.ts +++ b/packages/manager/src/features/Billing/GooglePayProvider.ts @@ -10,7 +10,7 @@ import { VariantType } from 'notistack'; import { GPAY_CLIENT_ENV, GPAY_MERCHANT_ID } from 'src/constants'; import { reportException } from 'src/exceptionReporting'; import { PaymentMessage } from 'src/features/Billing/BillingPanels/PaymentInfoPanel/AddPaymentMethodDrawer/AddPaymentMethodDrawer'; -import { accountQueries } from 'src/queries/account/queries'; +import { accountQueries } from '@linode/queries'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; const merchantInfo: google.payments.api.MerchantInfo = { diff --git a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx index 45d5396ff5e..fcf1320d5da 100644 --- a/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx +++ b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx @@ -2,7 +2,7 @@ import { getInvoice, getInvoiceItems } from '@linode/api-v4/lib/account'; import { Box, Button, IconButton, Notice, Paper, Typography } from '@linode/ui'; import KeyboardArrowLeft from '@mui/icons-material/KeyboardArrowLeft'; import { useTheme } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; import { useParams } from 'react-router-dom'; @@ -14,10 +14,9 @@ import { LandingHeader } from 'src/components/LandingHeader'; import { Link } from 'src/components/Link'; import { printInvoice } from 'src/features/Billing/PdfGenerator/PdfGenerator'; import { useFlags } from 'src/hooks/useFlags'; -import { useAccount } from 'src/queries/account/account'; -import { useRegionsQuery } from 'src/queries/regions/regions'; +import { useAccount, useRegionsQuery } from '@linode/queries'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { getAll } from 'src/utilities/getAll'; +import { getAll } from '@linode/utilities'; import { getShouldUseAkamaiBilling } from '../billingUtils'; import { invoiceCreatedAfterDCPricingLaunch } from '../PdfGenerator/utils'; @@ -125,10 +124,21 @@ export const InvoiceDetail = () => { padding: `${theme.spacing(2)} ${theme.spacing(3)}`, }} > - - + + - + { {account && invoice && items && ( <> @@ -188,7 +200,11 @@ export const InvoiceDetail = () => { )} - + {invoice && ( Total:{' '} @@ -201,7 +217,7 @@ export const InvoiceDetail = () => { - + {pdfGenerationError && ( Failed generating PDF. )} @@ -212,7 +228,7 @@ export const InvoiceDetail = () => { shouldShowRegion={shouldShowRegion} /> - + {invoice && ( ({ useResourcesQuery: queryMocks.useResourcesQuery, })); -vi.mock('src/queries/regions/regions', () => ({ - ...vi.importActual('src/queries/regions/regions'), +vi.mock('@linode/queries', async (importOriginal) => ({ + ...(await importOriginal()), useRegionsQuery: queryMocks.useRegionsQuery, })); @@ -132,8 +132,5 @@ describe('AlertDetail component tests', () => { const validateBreadcrumbs = (link: HTMLElement) => { expect(link).toBeInTheDocument(); expect(link).toHaveTextContent('Definitions'); - expect(link.closest('a')).toHaveAttribute( - 'href', - '/monitor/alerts/definitions' - ); + expect(link.closest('a')).toHaveAttribute('href', '/alerts/definitions'); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx index 25c2a929a28..691e3127f1b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetail.tsx @@ -1,11 +1,11 @@ -import { Box, Chip, CircleProgress, Typography } from '@linode/ui'; +import { Box, Chip, CircleProgress, ErrorState, Typography } from '@linode/ui'; import { styled, useTheme } from '@mui/material'; import React from 'react'; import { useParams } from 'react-router-dom'; import AlertsIcon from 'src/assets/icons/entityIcons/alerts.svg'; import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Placeholder } from 'src/components/Placeholder/Placeholder'; import { useAlertDefinitionQuery } from 'src/queries/cloudpulse/alerts'; @@ -38,12 +38,12 @@ export const AlertDetail = () => { const overrides = [ { label: 'Definitions', - linkTo: '/monitor/alerts/definitions', + linkTo: '/alerts/definitions', position: 1, }, { label: 'Details', - linkTo: `/monitor/alerts/definitions/details/${serviceType}/${alertId}`, + linkTo: `/alerts/definitions/details/${serviceType}/${alertId}`, position: 2, }, ]; @@ -96,8 +96,10 @@ export const AlertDetail = () => { service_type: alertServiceType, type, } = alertDetails; + return ( <> + @@ -169,10 +171,10 @@ export const StyledAlertChip = styled(Chip, { borderRadius?: string; }>(({ borderRadius, theme }) => ({ '& .MuiChip-label': { - color: theme.tokens.content.Text.Primary.Default, + color: theme.tokens.alias.Content.Text.Primary.Default, marginRight: theme.spacing(1), }, - backgroundColor: theme.tokens.background.Normal, + backgroundColor: theme.tokens.alias.Background.Normal, borderRadius: borderRadius || 0, height: theme.spacing(3), })); @@ -180,6 +182,6 @@ export const StyledAlertChip = styled(Chip, { export const StyledAlertTypography = styled(Typography, { label: 'StyledAlertTypography', })(({ theme }) => ({ - color: theme.tokens.content.Text.Primary.Default, + color: theme.tokens.alias.Content.Text.Primary.Default, fontSize: theme.typography.body1.fontSize, })); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx index 55a448d6dbc..e486d3a92b2 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailCriteria.tsx @@ -38,7 +38,15 @@ export const AlertDetailCriteria = React.memo((props: CriteriaProps) => { Trigger Alert When: - + { Criteria - + Notification Channels - + {channels.map((notificationChannel, index) => { const { channel_type, id, label } = notificationChannel; return ( diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailOverview.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailOverview.tsx index b0751a1a611..dfb0272178d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailOverview.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsDetail/AlertDetailOverview.tsx @@ -43,7 +43,13 @@ export const AlertDetailOverview = React.memo((props: OverviewProps) => { Overview - + {value.map((label, index) => ( 0 ? -1 : 0} + sx={{ + marginLeft: mergeChips && index > 0 ? -1 : 0, + }} > { + it('should show confirmation dialog', () => { + const { getByTestId, getByText } = renderWithTheme( + + ); + + expect(getByTestId('confirmation-dialog')).toBeInTheDocument(); + expect(getByText(`Disable ${alert.label} Alert?`)).toBeVisible(); + expect( + getByText(`Are you sure you want to disable the alert for ${entityName}?`) + ).toBeInTheDocument(); + }); + it('should click confirm button', async () => { + const { getByText } = renderWithTheme( + + ); + + const button = getByText('Disable'); + + await userEvent.click(button); + + expect(confirmFunction).toBeCalledWith(alert, true); + }); + it('should show enable text', async () => { + const { getByTestId, getByText } = renderWithTheme( + + ); + + expect(getByTestId('confirmation-dialog')).toBeInTheDocument(); + expect(getByText(`Enable ${alert.label} Alert?`)).toBeVisible(); + expect( + getByText(`Are you sure you want to enable the alert for ${entityName}?`) + ).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertConfirmationDialog.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertConfirmationDialog.tsx new file mode 100644 index 00000000000..9afecdfce86 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertConfirmationDialog.tsx @@ -0,0 +1,89 @@ +import { ActionsPanel, Typography } from '@linode/ui'; +import React from 'react'; + +import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; + +import type { Alert } from '@linode/api-v4'; + +interface AlertConfirmationDialogProps { + /** + * alert object of the selected row + */ + alert: Alert; + + /** + * Name of the selected entity + */ + entityName: string; + + /** + * Handler function for cancel button + */ + handleCancel: () => void; + + /** + * Handler function for enable/disable button + * @param alert selected alert from the row + * @param currentStatus current state of the toggle button + */ + handleConfirm: (alert: Alert, currentStatus: boolean) => void; + + /** + * Current state of the toggle button whether active or not + */ + isActive: boolean; + + /** + * Loading state of the confirmation dialog + */ + isLoading?: boolean; + + /** + * Current state of the confirmation dialoge whether open or not + */ + isOpen: boolean; +} + +export const AlertConfirmationDialog = React.memo( + (props: AlertConfirmationDialogProps) => { + const { + alert, + entityName, + handleCancel, + handleConfirm, + isActive, + isLoading = false, + isOpen, + } = props; + + const actionsPanel = ( + handleConfirm(alert, isActive), + }} + secondaryButtonProps={{ + disabled: isLoading, + label: 'Cancel', + onClick: handleCancel, + }} + /> + ); + + return ( + + + Are you sure you want to {isActive ? 'disable' : 'enable'} the alert + for {entityName}? + + + ); + } +); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx index 17422740458..1f6af7c395c 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsDefinitionLanding.tsx @@ -1,35 +1,25 @@ import * as React from 'react'; -import { Route, Switch } from 'react-router-dom'; +import { Redirect, Route, Switch, useRouteMatch } from 'react-router-dom'; import { AlertDetail } from '../AlertsDetail/AlertDetail'; import { AlertListing } from '../AlertsListing/AlertListing'; import { CreateAlertDefinition } from '../CreateAlert/CreateAlertDefinition'; -import { EditAlertResources } from '../EditAlert/EditAlertResources'; +import { EditAlertLanding } from '../EditAlert/EditAlertLanding'; export const AlertDefinitionLanding = () => { + const { url } = useRouteMatch(); + return ( - - + + - - + + + - + ); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx index a3afae0ab7c..f1fe04dfae0 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsLanding/AlertsLanding.tsx @@ -1,4 +1,5 @@ -import { Box, Paper } from '@linode/ui'; +import { Paper } from '@linode/ui'; +import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; import { Redirect, @@ -9,13 +10,21 @@ import { useRouteMatch, } from 'react-router-dom'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { TabLinkList } from 'src/components/Tabs/TabLinkList'; import { Tabs } from 'src/components/Tabs/Tabs'; import { useFlags } from 'src/hooks/useFlags'; import { AlertDefinitionLanding } from './AlertsDefinitionLanding'; -import type { EnabledAlertTab } from '../../CloudPulseTabs'; +import type { Tab } from 'src/components/Tabs/TabLinkList'; + +export type EnabledAlertTab = { + isEnabled: boolean; + tab: Tab; +}; export const AlertsLanding = React.memo(() => { const flags = useFlags(); @@ -51,31 +60,35 @@ export const AlertsLanding = React.memo(() => { }; return ( - + }> + - - - - - - - + + + + + + + + + + - + ); }); + +export const cloudPulseAlertsLandingLazyRoute = createLazyRoute('/alerts')({ + component: AlertsLanding, +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx index 19a58afc4f3..29338d9c14f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.test.tsx @@ -21,11 +21,16 @@ queryMocks.useEditAlertDefinition.mockReturnValue({ mutateAsync: vi.fn().mockResolvedValue({}), reset: vi.fn(), }); - +const mockScroll = vi.fn(); describe('Alert List Table test', () => { it('should render the alert landing table ', async () => { const { getByText } = renderWithTheme( - + ); expect(getByText('Alert Name')).toBeVisible(); expect(getByText('Service')).toBeVisible(); @@ -40,6 +45,7 @@ describe('Alert List Table test', () => { alerts={[]} error={[{ reason: 'Error in fetching the alerts' }]} isLoading={false} + scrollToElement={mockScroll} services={[]} /> ); @@ -60,6 +66,7 @@ describe('Alert List Table test', () => { }), ]} isLoading={false} + scrollToElement={mockScroll} services={[{ label: 'Linode', value: 'linode' }]} /> ); @@ -82,6 +89,7 @@ describe('Alert List Table test', () => { ); @@ -98,6 +106,7 @@ describe('Alert List Table test', () => { ); @@ -110,7 +119,9 @@ describe('Alert List Table test', () => { it('should show error snackbar when enabling alert fails', async () => { queryMocks.useEditAlertDefinition.mockReturnValue({ - mutateAsync: vi.fn().mockRejectedValue({}), + mutateAsync: vi + .fn() + .mockRejectedValue([{ reason: 'Enabling alert failed' }]), }); const alert = alertFactory.build({ status: 'disabled', type: 'user' }); @@ -118,6 +129,7 @@ describe('Alert List Table test', () => { ); @@ -131,7 +143,9 @@ describe('Alert List Table test', () => { it('should show error snackbar when disabling alert fails', async () => { queryMocks.useEditAlertDefinition.mockReturnValue({ - mutateAsync: vi.fn().mockRejectedValue({}), + mutateAsync: vi + .fn() + .mockRejectedValue([{ reason: 'Disabling alert failed' }]), }); const alert = alertFactory.build({ status: 'enabled', type: 'user' }); @@ -139,6 +153,7 @@ describe('Alert List Table test', () => { ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx index 0844f758b87..ccd5d2a516c 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx @@ -33,6 +33,10 @@ export interface AlertsListTableProps { * A boolean indicating whether the alerts are loading */ isLoading: boolean; + /** + * Callback to scroll to the button element on page change + */ + scrollToElement: () => void; /** * The list of services to display in the table */ @@ -40,7 +44,7 @@ export interface AlertsListTableProps { } export const AlertsListTable = React.memo((props: AlertsListTableProps) => { - const { alerts, error, isLoading, services } = props; + const { alerts, error, isLoading, scrollToElement, services } = props; const _error = error ? getAPIErrorOrDefault(error, 'Error in fetching the alerts.') : undefined; @@ -61,7 +65,7 @@ export const AlertsListTable = React.memo((props: AlertsListTableProps) => { const errorStatus = toggleStatus === 'disabled' ? 'Disabling' : 'Enabling'; editAlertDefinition({ - alertId: String(alert.id), + alertId: alert.id, serviceType: alert.service_type, status: toggleStatus, }) @@ -71,9 +75,13 @@ export const AlertsListTable = React.memo((props: AlertsListTableProps) => { variant: 'success', }); }) - .catch(() => { + .catch((updateError: APIError[]) => { // Handle error - enqueueSnackbar(`${errorStatus} alert failed`, { + const errorResponse = getAPIErrorOrDefault( + updateError, + `${errorStatus} alert failed` + ); + enqueueSnackbar(errorResponse[0].reason, { variant: 'error', }); }); @@ -85,7 +93,7 @@ export const AlertsListTable = React.memo((props: AlertsListTableProps) => { {({ data: orderedData, handleOrderChange, order, orderBy }) => ( @@ -99,17 +107,26 @@ export const AlertsListTable = React.memo((props: AlertsListTableProps) => { pageSize, }) => ( <> - + {AlertListingTableLabelMap.map((value) => ( { + if (order) { + handleOrderChange(orderBy, order); + handlePageChange(1); + } + }} active={orderBy === value.label} data-qa-header={value.label} data-qa-sorting={value.label} direction={order} - handleClick={handleOrderChange} key={value.label} label={value.label} noWrap @@ -143,12 +160,24 @@ export const AlertsListTable = React.memo((props: AlertsListTableProps) => {
{ + handlePageChange(page); + requestAnimationFrame(() => { + scrollToElement(); + }); + }} + handleSizeChange={(pageSize) => { + handlePageSizeChange(pageSize); + handlePageChange(1); + requestAnimationFrame(() => { + scrollToElement(); + }); + }} count={count} eventCategory="Alert Definitions Table" - handlePageChange={handlePageChange} - handleSizeChange={handlePageSizeChange} page={page} pageSize={pageSize} + sx={{ border: 0 }} /> )} diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx index 3911fce0b2b..0fbd2921b46 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx @@ -4,19 +4,21 @@ import { useHistory, useRouteMatch } from 'react-router-dom'; import AlertsIcon from 'src/assets/icons/entityIcons/alerts.svg'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; -import { StyledPlaceholder } from 'src/features/StackScripts/StackScriptBase/StackScriptBase.styles'; +import { Placeholder } from 'src/components/Placeholder/Placeholder'; import { useAllAlertDefinitionsQuery } from 'src/queries/cloudpulse/alerts'; import { useCloudPulseServiceTypes } from 'src/queries/cloudpulse/services'; import { alertStatusOptions } from '../constants'; +import { scrollToElement } from '../Utils/AlertResourceUtils'; import { AlertsListTable } from './AlertListTable'; import type { Item } from '../constants'; import type { Alert, AlertServiceType, AlertStatusType } from '@linode/api-v4'; const searchAndSelectSx = { + lg: '250px', md: '300px', - sm: '500px', + sm: '400px', xs: '300px', }; @@ -30,6 +32,7 @@ export const AlertListing = () => { isLoading: serviceTypesLoading, } = useCloudPulseServiceTypes(true); + const topRef = React.useRef(null); const getServicesList = React.useMemo((): Item< string, AlertServiceType @@ -117,9 +120,9 @@ export const AlertListing = () => { statusFilters, ]); - if (alerts && alerts.length == 0) { + if (alerts && alerts.length === 0) { return ( - { alignItems={{ lg: 'flex-end', md: 'flex-start' }} display="flex" flexDirection={{ lg: 'row', md: 'column', sm: 'column', xs: 'column' }} + flexWrap="wrap" gap={3} justifyContent="space-between" + ref={topRef} > { data-qa-filter="alert-service-filter" data-testid="alert-service-filter" label="" - limitTags={2} + limitTags={1} loading={serviceTypesLoading} multiple noMarginTop @@ -202,6 +207,7 @@ export const AlertListing = () => { data-qa-filter="alert-status-filter" data-testid="alert-status-filter" label="" + limitTags={1} multiple noMarginTop options={alertStatusOptions} @@ -218,7 +224,7 @@ export const AlertListing = () => { paddingBottom: 0, paddingTop: 0, whiteSpace: 'noWrap', - width: { md: '150px', xs: '200px' }, + width: { lg: '120px', md: '120px', sm: '150px', xs: '150px' }, }} buttonType="primary" data-qa-button="create-alert" @@ -232,6 +238,7 @@ export const AlertListing = () => { alerts={getAlertsList} error={error ?? undefined} isLoading={isLoading} + scrollToElement={() => scrollToElement(topRef.current ?? null)} services={getServicesList} /> diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx index 0d398787545..59d8145cb41 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.test.tsx @@ -1,10 +1,10 @@ +import { capitalize } from '@linode/utilities'; import userEvent from '@testing-library/user-event'; import { createMemoryHistory } from 'history'; import * as React from 'react'; import { Router } from 'react-router-dom'; import { alertFactory } from 'src/factories/cloudpulse/alerts'; -import { capitalize } from 'src/utilities/capitalize'; import { renderWithTheme, wrapWithTableBody } from 'src/utilities/testHelpers'; import { AlertTableRow } from './AlertTableRow'; @@ -67,8 +67,8 @@ describe('Alert Row', () => { it('alert labels should have hyperlinks to the details page', () => { const alert = alertFactory.build({ status: 'enabled' }); const history = createMemoryHistory(); - history.push('/monitor/alerts/definitions'); - const link = `/monitor/alerts/definitions/detail/${alert.service_type}/${alert.id}`; + history.push('/alerts/definitions'); + const link = `/alerts/definitions/detail/${alert.service_type}/${alert.id}`; const renderedAlert = ( { await userEvent.click(ActionMenu); expect(getByText('Disable')).toBeInTheDocument(); }); + + it("should disable 'Disable' action item in menu if alert has no enabled/disabled status", async () => { + const alert = alertFactory.build({ status: 'in progress', type: 'user' }); + const { getByLabelText, getByText } = renderWithTheme( + + ); + const ActionMenu = getByLabelText(`Action menu for Alert ${alert.label}`); + await userEvent.click(ActionMenu); + expect(getByText('In Progress')).toBeInTheDocument(); + expect(getByText('Disable').closest('li')).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + + it("should disable 'Edit' action item in menu if alert has no enabled/disabled status", async () => { + const alert = alertFactory.build({ status: 'in progress', type: 'user' }); + const { getByLabelText, getByText } = renderWithTheme( + + ); + const ActionMenu = getByLabelText(`Action menu for Alert ${alert.label}`); + await userEvent.click(ActionMenu); + expect(getByText('In Progress')).toBeInTheDocument(); + expect(getByText('Edit').closest('li')).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx index 99059d706a2..2c0f244ce89 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx @@ -6,14 +6,14 @@ import { Link } from 'src/components/Link'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { capitalize } from 'src/utilities/capitalize'; import { formatDate } from 'src/utilities/formatDate'; +import { alertStatusToIconStatusMap, alertStatuses } from '../constants'; import { AlertActionMenu } from './AlertActionMenu'; import type { Item } from '../constants'; import type { ActionHandlers } from './AlertActionMenu'; -import type { Alert, AlertServiceType, AlertStatusType } from '@linode/api-v4'; +import type { Alert, AlertServiceType } from '@linode/api-v4'; interface Props { /** @@ -30,15 +30,6 @@ interface Props { services: Item[]; } -const getStatus = (status: AlertStatusType) => { - if (status === 'enabled') { - return 'active'; - } else if (status === 'disabled') { - return 'inactive'; - } - return 'other'; -}; - export const AlertTableRow = (props: Props) => { const { alert, handlers, services } = props; const location = useLocation(); @@ -53,8 +44,11 @@ export const AlertTableRow = (props: Props) => { - - {capitalize(status)} + + {alertStatuses[status]} diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts index 95dc2bb06bf..516c8e10e86 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts @@ -1,3 +1,4 @@ +import type { TableColumnHeader } from '../ContextualView/AlertInformationActionTable'; import type { AlertStatusType, AlertStatusUpdateType } from '@linode/api-v4'; export const AlertListingTableLabelMap = [ @@ -29,4 +30,12 @@ export const statusToActionMap: Record< > = { disabled: 'Enable', enabled: 'Disable', + failed: 'Disable', + 'in progress': 'Disable', }; + +export const AlertContextualViewTableHeaderMap: TableColumnHeader[] = [ + { columnName: 'Alert Name', label: 'label' }, + { columnName: 'Metric Threshold', label: 'id' }, + { columnName: 'Alert Type', label: 'type' }, +]; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.tsx index 18058cf0595..f200dfb19ad 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsRegionFilter.tsx @@ -38,6 +38,9 @@ export const AlertsRegionFilter = React.memo((props: AlertsRegionProps) => { placement: 'bottom', }, }} + sx={{ + width: '100%', + }} textFieldProps={{ hideLabel: true, }} diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx index 8ac289c2790..1e59a95f23f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx @@ -15,8 +15,8 @@ vi.mock('src/queries/cloudpulse/resources', () => ({ useResourcesQuery: queryMocks.useResourcesQuery, })); -vi.mock('src/queries/regions/regions', () => ({ - ...vi.importActual('src/queries/regions/regions'), +vi.mock('@linode/queries', async (importOriginal) => ({ + ...(await importOriginal()), useRegionsQuery: queryMocks.useRegionsQuery, })); @@ -128,7 +128,7 @@ describe('AlertResources component tests', () => { }); // search with invalid text and a region await userEvent.type(searchInput, 'dummy'); - await userEvent.click(getByRole('button', { name: 'Open' })); + await userEvent.click(getByPlaceholderText('Select Regions')); await userEvent.click(getByTestId(regions[0].id)); await userEvent.click(getByRole('button', { name: 'Close' })); await waitFor(() => { @@ -254,8 +254,8 @@ describe('AlertResources component tests', () => { await userEvent.click(getByText('Select All')); expect(handleResourcesSelection).toHaveBeenLastCalledWith(['1', '2', '3']); - // click unselect all in notice and test - await userEvent.click(getByText('Unselect All')); + // click deselect all in notice and test + await userEvent.click(getByText('Deselect All')); expect(handleResourcesSelection).toHaveBeenLastCalledWith([]); }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx index 553758d83c3..ef852381734 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx @@ -1,11 +1,12 @@ +import { useRegionsQuery } from '@linode/queries'; import { Checkbox, CircleProgress, Stack, Typography } from '@linode/ui'; import { Grid } from '@mui/material'; import React from 'react'; -import EntityIcon from 'src/assets/icons/entityIcons/alerts.svg'; +import EntityIcon from 'src/assets/icons/entityIcons/alertsresources.svg'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { useFlags } from 'src/hooks/useFlags'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; -import { useRegionsQuery } from 'src/queries/regions/regions'; import { StyledPlaceholder } from '../AlertsDetail/AlertDetail'; import { @@ -13,11 +14,13 @@ import { getFilteredResources, getRegionOptions, getRegionsIdRegionMap, + getSupportedRegionIds, scrollToElement, } from '../Utils/AlertResourceUtils'; +import { AlertsNoticeMessage } from '../Utils/AlertsNoticeMessage'; import { AlertResourcesFilterRenderer } from './AlertsResourcesFilterRenderer'; import { AlertsResourcesNotice } from './AlertsResourcesNotice'; -import { serviceToFiltersMap } from './constants'; +import { databaseTypeClassMap, serviceToFiltersMap } from './constants'; import { DisplayAlertResources } from './DisplayAlertResources'; import type { AlertInstance } from './DisplayAlertResources'; @@ -54,6 +57,11 @@ export interface AlertResourcesProp { */ alertType: AlertDefinitionType; + /** + * The error text that needs to displayed incase needed + */ + errorText?: string; + /** * Callback for publishing the selected resources */ @@ -69,6 +77,11 @@ export interface AlertResourcesProp { */ isSelectionsNeeded?: boolean; + /** + * The maximum number of elements that can be selected + */ + maxSelectionCount?: number; + /** * The element until which we need to scroll on pagination and order change */ @@ -80,7 +93,7 @@ export interface AlertResourcesProp { serviceType?: AlertServiceType; } -export type SelectUnselectAll = 'Select All' | 'Unselect All'; +export type SelectDeselectAll = 'Deselect All' | 'Select All'; export const AlertResources = React.memo((props: AlertResourcesProp) => { const { @@ -88,9 +101,11 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { alertLabel, alertResourceIds, alertType, + errorText, handleResourcesSelection, hideLabel, isSelectionsNeeded, + maxSelectionCount, scrollElement, serviceType, } = props; @@ -102,11 +117,33 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { const [selectedOnly, setSelectedOnly] = React.useState(false); const [additionalFilters, setAdditionalFilters] = React.useState< Record - >({ engineType: undefined }); + >({ engineType: undefined, tags: undefined }); + + const { + data: regions, + isError: isRegionsError, + isLoading: isRegionsLoading, + } = useRegionsQuery(); + + const flags = useFlags(); + // Validate launchDarkly region ids with the ids from regionOptions prop + const supportedRegionIds = getSupportedRegionIds( + flags.aclpResourceTypeMap, + serviceType + ); const xFilterToBeApplied: Filter | undefined = React.useMemo(() => { + const regionFilter: Filter = supportedRegionIds + ? { + '+or': supportedRegionIds.map((regionId) => ({ + region: regionId, + })), + } + : {}; + + // if service type is other than dbaas, return only region filter if (serviceType !== 'dbaas') { - return undefined; // No x-filters needed for other serviceTypes + return regionFilter; } // Always include platform filter for 'dbaas' @@ -117,22 +154,26 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { return platformFilter; } - // Apply type filter only for system alerts with a valid alertClass + // Dynamically exclude 'dedicated' if alertClass is 'shared' + const filteredTypes = + alertClass === 'shared' + ? Object.keys(databaseTypeClassMap).filter( + (type) => type !== 'dedicated' + ) + : [alertClass]; + + // Apply type filter only for DBaaS user alerts with a valid alertClass based on above filtered types const typeFilter: Filter = { - type: { - '+contains': alertClass, - }, + '+or': filteredTypes.map((dbType) => ({ + type: { + '+contains': dbType, + }, + })), }; - // Combine both filters - return { ...platformFilter, ...typeFilter }; - }, [alertClass, alertType, serviceType]); - - const { - data: regions, - isError: isRegionsError, - isLoading: isRegionsLoading, - } = useRegionsQuery(); + // Combine all the filters + return { ...platformFilter, '+and': [typeFilter, regionFilter] }; + }, [alertClass, alertType, serviceType, supportedRegionIds]); const { data: resources, @@ -239,15 +280,15 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { ); const handleAllSelection = React.useCallback( - (action: SelectUnselectAll) => { + (action: SelectDeselectAll) => { if (!resources) { return; } let currentSelections: string[] = []; - if (action === 'Unselect All') { - // Unselect all + if (action === 'Deselect All') { + // Deselect all setSelectedResources([]); } else { // Select all @@ -281,9 +322,14 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { )} ); @@ -306,9 +352,11 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { alert for. )} - + { handleFilterChange, handleFilteredRegionsChange, regionOptions, + tagOptions: Array.from( + new Set( + resources + ? resources.flatMap(({ tags }) => tags ?? []) + : [] + ) + ), })} component={component} /> @@ -359,15 +414,27 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { /> )} - {isSelectionsNeeded && !isDataLoadingError && resources?.length && ( - - - + {errorText?.length && ( + + )} + {maxSelectionCount !== undefined && ( + )} + {isSelectionsNeeded && + !isDataLoadingError && + resources && + resources.length > 0 && ( + + + + )} diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesFilterRenderer.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesFilterRenderer.test.tsx index a8bbc758b57..c678a09540b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesFilterRenderer.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesFilterRenderer.test.tsx @@ -15,6 +15,7 @@ describe('AlertsResourcesFilterRenderer', () => { handleFilterChange: handleFilterChangeMock, handleFilteredRegionsChange: handleFilterChangeMock, regionOptions: [], + tagOptions: [], }); const enginePropKeys = Object.keys(engineProps); expect(enginePropKeys.includes('handleFilterChange')).toBeTruthy(); @@ -36,6 +37,7 @@ describe('AlertsResourcesFilterRenderer', () => { handleFilterChange: handleFilterChangeMock, handleFilteredRegionsChange: handleFilterChangeMock, regionOptions: [], + tagOptions: [], }); const regionPropKeys = Object.keys(regionProps); expect(regionPropKeys.includes('handleFilterChange')).toBeFalsy(); @@ -50,5 +52,26 @@ describe('AlertsResourcesFilterRenderer', () => { ); expect(getByPlaceholderText('Select Regions')).toBeInTheDocument(); + + const tagProps = getAlertResourceFilterProps({ + filterKey: 'tags', + handleFilterChange: handleFilterChangeMock, + handleFilteredRegionsChange: handleFilterChangeMock, + regionOptions: [], + tagOptions: ['tag1', 'tag2'], + }); + const tagPropKeys = Object.keys(tagProps); + expect(tagPropKeys.includes('handleFilterChange')).toBeTruthy(); + expect(tagPropKeys.includes('handleSelectionChange')).toBeFalsy(); + + // Check for region filter + renderWithTheme( + + ); + + expect(getByPlaceholderText('Select Tags')).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesFilterRenderer.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesFilterRenderer.tsx index 905e0fa1bfb..0223f10c7c4 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesFilterRenderer.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesFilterRenderer.tsx @@ -2,8 +2,7 @@ import React from 'react'; import NullComponent from 'src/components/NullComponent'; -import type { AlertsEngineOptionProps } from './AlertsEngineTypeFilter'; -import type { AlertsRegionProps } from './AlertsRegionFilter'; +import type { AlertResourceFiltersProps } from './types'; import type { MemoExoticComponent } from 'react'; export interface AlertResourcesFilterRendererProps { @@ -11,12 +10,12 @@ export interface AlertResourcesFilterRendererProps { * The filter component to be rendered (e.g., `AlertsEngineTypeFilter`, `AlertsRegionFilter`). */ component?: MemoExoticComponent< - React.ComponentType + React.ComponentType >; /** * Props that will be passed to the filter component. */ - componentProps: AlertsEngineOptionProps | AlertsRegionProps; + componentProps: AlertResourceFiltersProps; } export const AlertResourcesFilterRenderer = ({ diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesNotice.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesNotice.tsx index 7fe60b58c52..7da5280eabf 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesNotice.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResourcesNotice.tsx @@ -1,16 +1,14 @@ -import { Notice, Typography } from '@linode/ui'; +import { Button, Notice, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import React from 'react'; -import { LinkButton } from 'src/components/LinkButton'; - -import type { SelectUnselectAll } from './AlertsResources'; +import type { SelectDeselectAll } from './AlertsResources'; interface AlertResourceNoticeProps { /** - * Callback to handle selection changes (select all or unselect all). + * Callback to handle selection changes (select all or deselect all). */ - handleSelectionChange: (action: SelectUnselectAll) => void; + handleSelectionChange: (action: SelectDeselectAll) => void; /** * The number of currently selected resources. @@ -27,51 +25,40 @@ export const AlertsResourcesNotice = React.memo( (props: AlertResourceNoticeProps) => { const { handleSelectionChange, selectedResources, totalResources } = props; const isSelectAll = selectedResources !== totalResources; + const buttonText = isSelectAll ? 'Select All' : 'Deselect All'; return ( ({ - fontFamily: theme.font.bold, + fontFamily: theme.tokens.alias.Typography.Body.Bold, })} data-testid="selection_notice" - variant="body2" > {selectedResources} of {totalResources} resources are selected. - {isSelectAll && ( - { - handleSelectionChange('Select All'); - }} - aria-label="Select All Resources" - data-testid="select_all_notice" - > - Select All - - )} - {!isSelectAll && ( - { - handleSelectionChange('Unselect All'); - }} - aria-label="Unselect All Resources" - data-testid="unselect_all_notice" - > - Unselect All - - )} + ); } ); -const StyledNotice = styled(Notice, { label: 'StyledNotice' })(({ theme }) => ({ - alignItems: 'center', - background: theme.tokens.background.Normal, - borderRadius: 1, - display: 'flex', - flexWrap: 'nowrap', - marginBottom: 0, - padding: theme.spacing(2), -})); +export const StyledNotice = styled(Notice, { label: 'StyledNotice' })( + ({ theme }) => ({ + alignItems: 'center', + background: theme.tokens.alias.Background.Normal, + borderRadius: 1, + display: 'flex', + flexWrap: 'nowrap', + marginBottom: 0, + padding: theme.tokens.spacing.S16, + }) +); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsTagsFilter.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsTagsFilter.test.tsx new file mode 100644 index 00000000000..fb0ba59af78 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsTagsFilter.test.tsx @@ -0,0 +1,32 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AlertsTagFilter } from './AlertsTagsFilter'; + +describe('AlertsTagsFilters', () => { + it('calls handleSelection with correct arguments when list of tags is selected', async () => { + // Mock the handleSelection function + const handleSelection = vi.fn(); + const tagsOptions = ['tag1', 'tag2', 'tag3']; + + // Render the component + const { getByRole } = renderWithTheme( + + ); + + await userEvent.click(getByRole('button', { name: 'Open' })); + expect(getByRole('option', { name: 'tag1' })).toBeInTheDocument(); + // Select an option + await userEvent.click(getByRole('option', { name: 'tag1' })); + await userEvent.click(getByRole('option', { name: 'tag2' })); + + await userEvent.click(getByRole('button', { name: 'Close' })); + // Assert that the handleSelection function is called with the expected arguments + expect(handleSelection).toHaveBeenLastCalledWith(['tag1', 'tag2'], 'tags'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsTagsFilter.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsTagsFilter.tsx new file mode 100644 index 00000000000..29ffd05de82 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsTagsFilter.tsx @@ -0,0 +1,75 @@ +import { Autocomplete } from '@linode/ui'; +import React from 'react'; + +import type { AlertAdditionalFilterKey } from './types'; + +export interface AlertsTagFilterProps { + /** + * Callback to publish the selected tags + */ + handleFilterChange: ( + tags: string[] | undefined, + type: AlertAdditionalFilterKey + ) => void; + + /** + * The unique set of tags that needs to be displayed + */ + tagOptions: string[]; +} + +interface AlertTags { + /** + * The label of the alert tag option + */ + label: string; +} + +export const AlertsTagFilter = React.memo((props: AlertsTagFilterProps) => { + const { handleFilterChange, tagOptions } = props; + const [selectedTags, setSelectedTags] = React.useState([]); + + const builtTagOptions: AlertTags[] = tagOptions.map((option) => ({ + label: option, + })); + + const handleFilterSelection = React.useCallback( + (_e: React.SyntheticEvent, tags: AlertTags[]) => { + setSelectedTags(tags); + handleFilterChange( + tags.length ? tags.map(({ label }) => label) : undefined, + 'tags' + ); + }, + [handleFilterChange] + ); + + return ( + option.label === value.label} + label="Tags" + limitTags={1} + multiple + onChange={handleFilterSelection} + options={builtTagOptions} + placeholder={selectedTags.length ? '' : 'Select Tags'} + value={selectedTags} + /> + ); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx index d71903e844c..7be38b48811 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx @@ -16,7 +16,7 @@ import { isAllPageSelected, isSomeSelected } from '../Utils/AlertResourceUtils'; import { serviceTypeBasedColumns } from './constants'; import type { AlertServiceType } from '@linode/api-v4'; -import type { Order } from 'src/hooks/useOrder'; +import type { Order } from '@linode/utilities'; export interface AlertInstance { /** @@ -40,6 +40,11 @@ export interface AlertInstance { * The region associated with the instance */ region: string; + + /** + * The list of tags associated with the instance + */ + tags?: string[]; } export interface DisplayAlertResourceProp { diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/TextWithExtraInfo.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/TextWithExtraInfo.test.tsx new file mode 100644 index 00000000000..51740364c64 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/TextWithExtraInfo.test.tsx @@ -0,0 +1,43 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { TextWithExtraInfo } from './TextWithExtraInfo'; + +describe('TextWithExtraInfo Component', () => { + it('renders a dash when no values are provided', () => { + const { getByText } = renderWithTheme(); + expect(getByText('-')).toBeInTheDocument(); + }); + + it('renders a single chip when one value is provided', () => { + const { getByText } = renderWithTheme( + + ); + expect(getByText('Test Value')).toBeInTheDocument(); + }); + + it('renders a chip and a tooltip when multiple values are provided', async () => { + const { findByText, getByText } = renderWithTheme( + + ); + + expect(getByText('First')).toBeInTheDocument(); + expect(getByText('+2')).toBeInTheDocument(); + + // Simulate hover to show tooltip + await userEvent.hover(getByText('+2')); + expect(await findByText('Second')).toBeInTheDocument(); + expect(await findByText('Third')).toBeInTheDocument(); + }); + + it('does not render a tooltip when only one value is provided', async () => { + const { getByText, queryByText } = renderWithTheme( + + ); + + expect(getByText('Only One')).toBeInTheDocument(); + expect(queryByText('+1')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/TextWithExtraInfo.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/TextWithExtraInfo.tsx new file mode 100644 index 00000000000..94560bec1d2 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/TextWithExtraInfo.tsx @@ -0,0 +1,64 @@ +import { Box, Chip, Tooltip, Typography } from '@linode/ui'; +import React from 'react'; + +export interface TextWithInfoProp { + /** + * The list of texts that needs to be displayed with chip and tooltip setup + */ + values?: string[]; +} + +export const TextWithExtraInfo = ({ values }: TextWithInfoProp) => { + if (!values?.length) { + return -; + } + return ( + + ({ + backgroundColor: theme.color.tagButtonBg, + color: theme.color.tagButtonText, + })} + label={values[0]} + /> + {values.length > 1 && ( + + {values.slice(1).map((value, index) => ( + + {value} + + ))} + + } + > + + ({ + backgroundColor: theme.color.tagButtonBg, + color: theme.color.tagButtonText, + })} + label={`+${values.length - 1}`} + /> + + + )} + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts index ec019ee30c9..0202f4ea006 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/constants.ts @@ -1,15 +1,20 @@ +import React from 'react'; + import { engineTypeMap } from '../constants'; import { AlertsEngineTypeFilter } from './AlertsEngineTypeFilter'; import { AlertsRegionFilter } from './AlertsRegionFilter'; +import { AlertsTagFilter } from './AlertsTagsFilter'; +import { TextWithExtraInfo } from './TextWithExtraInfo'; import type { AlertInstance } from './DisplayAlertResources'; +import type { TextWithInfoProp } from './TextWithExtraInfo'; import type { AlertAdditionalFilterKey, EngineType, ServiceColumns, ServiceFilterConfig, } from './types'; -import type { AlertServiceType } from '@linode/api-v4'; +import type { AlertServiceType, DatabaseTypeClass } from '@linode/api-v4'; export const serviceTypeBasedColumns: ServiceColumns = { '': [ @@ -54,6 +59,14 @@ export const serviceTypeBasedColumns: ServiceColumns = { label: 'Region', sortingKey: 'region', }, + { + accessor: ({ tags }) => + React.createElement>(TextWithExtraInfo, { + values: tags ?? [], + }), + label: 'Tags', + sortingKey: 'tags', + }, ], }; @@ -66,10 +79,14 @@ export const serviceToFiltersMap: Record< { component: AlertsEngineTypeFilter, filterKey: 'engineType' }, { component: AlertsRegionFilter, filterKey: 'region' }, ], - linode: [{ component: AlertsRegionFilter, filterKey: 'region' }], // TODO: Add 'tags' filter in the future + linode: [ + { component: AlertsRegionFilter, filterKey: 'region' }, + { component: AlertsTagFilter, filterKey: 'tags' }, + ], }; export const applicableAdditionalFilterKeys: AlertAdditionalFilterKey[] = [ 'engineType', // Extendable in future for filter keys like 'tags', 'plan', etc. + 'tags', ]; export const alertAdditionalFilterKeyMap: Record< @@ -77,6 +94,7 @@ export const alertAdditionalFilterKeyMap: Record< keyof AlertInstance > = { engineType: 'engineType', // engineType filter selected here, will map to engineType property on AlertInstance + tags: 'tags', }; export const engineOptions: EngineType[] = [ @@ -89,3 +107,10 @@ export const engineOptions: EngineType[] = [ label: 'PostgreSQL', }, ]; + +export const databaseTypeClassMap: Record = { + dedicated: 'dedicated', + nanode: 'nanode', + premium: 'premium', + standard: 'standard', +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/types.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/types.ts index e8e03987da6..cae542fed81 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/types.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/types.ts @@ -1,5 +1,6 @@ import type { AlertsEngineOptionProps } from './AlertsEngineTypeFilter'; import type { AlertsRegionProps } from './AlertsRegionFilter'; +import type { AlertsTagFilterProps } from './AlertsTagsFilter'; import type { AlertServiceType } from '@linode/api-v4'; import type { MemoExoticComponent } from 'react'; @@ -7,9 +8,9 @@ export interface ColumnConfig { /** * Function to extract the value from a data object for display in the column. * @param data - The data object of type T. - * @returns The string representation of the column value. + * @returns The react node representation of the column value. */ - accessor: (data: T) => string; + accessor: (data: T) => React.ReactNode; /** * The label or title of the column to be displayed in the table header. @@ -43,7 +44,7 @@ export type ServiceColumns = Record< * Defines the available filter keys that can be used to filter alerts. * This type will be extended in the future to include other attributes like tags, plan, etc. */ -export type AlertFilterKey = 'engineType' | 'region'; // will be extended to have tags, plan etc., +export type AlertFilterKey = 'engineType' | 'region' | 'tags'; // will be extended to have tags, plan etc., /** * Represents the possible types for alert filter values. @@ -55,14 +56,19 @@ export type AlertFilterType = boolean | number | string | string[] | undefined; * Defines additional filter keys that can be used beyond the primary ones. * Future Extensions: Additional attributes like 'tags' and 'plan' can be added here. */ -export type AlertAdditionalFilterKey = 'engineType'; // will be extended to have tags, plan etc., +export type AlertAdditionalFilterKey = 'engineType' | 'tags'; // will be extended to have tags, plan etc., + +export type AlertResourceFiltersProps = + | AlertsEngineOptionProps + | AlertsRegionProps + | AlertsTagFilterProps; /** * Configuration for dynamically rendering service-specific filters. */ export interface ServiceFilterConfig { component: MemoExoticComponent< - React.ComponentType + React.ComponentType >; filterKey: AlertFilterKey; } diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionRow.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionRow.test.tsx new file mode 100644 index 00000000000..26c433a6a6e --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionRow.test.tsx @@ -0,0 +1,55 @@ +import { capitalize } from '@linode/utilities'; +import React from 'react'; + +import { alertFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { processMetricCriteria } from '../Utils/utils'; +import { AlertInformationActionRow } from './AlertInformationActionRow'; + +describe('Alert list table row', () => { + it('Should display the data', () => { + const alert = alertFactory.build(); + + const { getByText } = renderWithTheme( + + ); + + expect(getByText(alert.label)).toBeInTheDocument(); + expect(getByText(capitalize(alert.type))).toBeInTheDocument(); + }); + + it('Should display metric threshold', () => { + const alert = alertFactory.build(); + const processCriteria = processMetricCriteria(alert.rule_criteria.rules)[0]; + const { getByText } = renderWithTheme( + + ); + expect( + getByText( + `${processCriteria.label} ${processCriteria.metricOperator} ${processCriteria.threshold} ${processCriteria.unit}` + ) + ).toBeInTheDocument(); + }); + + it('Should have toggle button disabled', () => { + const alert = alertFactory.build(); + const { getByRole } = renderWithTheme( + + ); + + expect(getByRole('checkbox')).toHaveProperty('checked'); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionRow.tsx new file mode 100644 index 00000000000..0e7dbc33c35 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionRow.tsx @@ -0,0 +1,68 @@ +import { FormControlLabel, Toggle, Typography } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; +import React from 'react'; + +import { Link } from 'src/components/Link'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; + +import { processMetricCriteria } from '../Utils/utils'; +import { MetricThreshold } from './MetricThreshold'; + +import type { Alert } from '@linode/api-v4'; + +interface AlertInformationActionRowProps { + /** + * Alert object which should be dispalyed in the row + */ + alert: Alert; + + /** + * Handler function for the click of toggle button + * @param alert object for which toggle button is click + */ + handleToggle: (alert: Alert) => void; + + /** + * Status for the alert whether it is enabled or disabled + */ + status?: boolean; +} + +export const AlertInformationActionRow = ( + props: AlertInformationActionRowProps +) => { + const { alert, handleToggle, status = false } = props; + const { id, label, rule_criteria, service_type, type } = alert; + const metricThreshold = processMetricCriteria(rule_criteria.rules); + + return ( + + + handleToggle(alert)} /> + } + label={''} + /> + + + + {label} + + + + + + + ({ + font: theme.tokens.alias.Typography.Label.Regular.S, + })} + > + {capitalize(type)} + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.test.tsx new file mode 100644 index 00000000000..f00aadc8159 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.test.tsx @@ -0,0 +1,83 @@ +import { within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { alertFactory } from 'src/factories/cloudpulse/alerts'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AlertInformationActionTable } from './AlertInformationActionTable'; + +import type { + AlertInformationActionTableProps, + TableColumnHeader, +} from './AlertInformationActionTable'; + +const serviceType = 'linode'; +const entityId = '123'; +const entityName = 'test-instance'; +const alerts = [ + ...alertFactory.buildList(7, { + entity_ids: [entityId], + service_type: serviceType, + status: 'enabled', + }), +]; +const columns: TableColumnHeader[] = [ + { columnName: 'Alert Name', label: 'label' }, + { columnName: 'Metric Threshold', label: 'id' }, + { columnName: 'Alert Type', label: 'type' }, +]; +const props: AlertInformationActionTableProps = { + alerts, + columns, + entityId, + entityName, + orderByColumn: 'Alert Name', +}; + +describe('Alert Listing Reusable Table for contextual view', () => { + it('Should render alert table', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('Alert Name')).toBeInTheDocument(); + expect(getByText('Metric Threshold')).toBeInTheDocument(); + expect(getByText('Alert Type')).toBeInTheDocument(); + }); + + it('Should show message for empty table', () => { + const { getByText } = renderWithTheme( + + ); + + expect(getByText('No data to display.')).toBeInTheDocument(); + }); + + it('Shoud render table row toggle in table row', async () => { + const { findByTestId } = renderWithTheme( + + ); + const alert = alerts[0]; + const row = await findByTestId(alert.id); + + const checkbox = await within(row).findByRole('checkbox'); + + expect(checkbox).toHaveProperty('checked'); + }); + + it('Should show confirm dialog on checkbox click', async () => { + const { findByTestId, findByText } = renderWithTheme( + + ); + const alert = alerts[0]; + const row = await findByTestId(alert.id); + + const checkbox = await within(row).findByRole('checkbox'); + + await userEvent.click(checkbox); + + const text = await findByText(`Disable ${alert.label} Alert?`); + expect(text).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx new file mode 100644 index 00000000000..fdc98763b18 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx @@ -0,0 +1,204 @@ +import { Box } from '@linode/ui'; +import { Grid, TableBody, TableHead } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import React from 'react'; + +import OrderBy from 'src/components/OrderBy'; +import Paginate from 'src/components/Paginate'; +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Table } from 'src/components/Table'; +import { TableCell } from 'src/components/TableCell'; +import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper'; +import { TableRow } from 'src/components/TableRow'; +import { TableSortCell } from 'src/components/TableSortCell'; +import { + useAddEntityToAlert, + useRemoveEntityFromAlert, +} from 'src/queries/cloudpulse/alerts'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import { AlertConfirmationDialog } from '../AlertsLanding/AlertConfirmationDialog'; +import { AlertInformationActionRow } from './AlertInformationActionRow'; + +import type { APIError, Alert, EntityAlertUpdatePayload } from '@linode/api-v4'; + +export interface AlertInformationActionTableProps { + /** + * List of alerts to be displayed + */ + alerts: Alert[]; + + /** + * List of table headers for each column + */ + columns: TableColumnHeader[]; + + /** + * Id of the selected entity + */ + entityId: string; + + /** + * Name of the selected entity + */ + entityName: string; + + /** + * Error received from API + */ + error?: APIError[] | null; + + /** + * Column name by which columns will be ordered by default + */ + orderByColumn: string; +} + +export interface TableColumnHeader { + /** + * Name of the column to be displayed + */ + columnName: string; + + /** + * Corresponding key name in the alert object for which this column is + */ + label: string; +} + +export const AlertInformationActionTable = ( + props: AlertInformationActionTableProps +) => { + const { alerts, columns, entityId, entityName, error, orderByColumn } = props; + + const _error = error + ? getAPIErrorOrDefault(error, 'Error while fetching the alerts') + : undefined; + const { enqueueSnackbar } = useSnackbar(); + const [selectedAlert, setSelectedAlert] = React.useState({} as Alert); + const [isDialogOpen, setIsDialogOpen] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + + const { mutateAsync: addEntity } = useAddEntityToAlert(); + + const { mutateAsync: removeEntity } = useRemoveEntityFromAlert(); + + const handleCancel = () => { + setIsDialogOpen(false); + }; + const handleConfirm = React.useCallback( + (alert: Alert, currentStatus: boolean) => { + const payload: EntityAlertUpdatePayload = { + alert, + entityId, + }; + + setIsLoading(true); + (currentStatus ? removeEntity(payload) : addEntity(payload)) + .then(() => { + enqueueSnackbar( + `The alert settings for ${entityName} saved successfully.`, + { variant: 'success' } + ); + }) + .catch(() => { + enqueueSnackbar( + `${currentStatus ? 'Disabling' : 'Enabling'} alert failed.`, + { + variant: 'error', + } + ); + }) + .finally(() => { + setIsLoading(false); + setIsDialogOpen(false); + }); + }, + [addEntity, enqueueSnackbar, entityId, entityName, removeEntity] + ); + const handleToggle = (alert: Alert) => { + setIsDialogOpen(true); + setSelectedAlert(alert); + }; + return ( + <> + + {({ data: orderedData, handleOrderChange, order, orderBy }) => ( + + {({ + count, + data: paginatedAndOrderedAlerts, + handlePageChange, + handlePageSizeChange, + page, + pageSize, + }) => ( + + + + + + + {columns.map(({ columnName, label }) => { + return ( + + {columnName} + + ); + })} + + + + + {paginatedAndOrderedAlerts?.map((alert) => ( + + ))} + +
+
+ +
+ )} +
+ )} +
+ + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.test.tsx new file mode 100644 index 00000000000..aab9cf433a0 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.test.tsx @@ -0,0 +1,107 @@ +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { alertFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AlertReusableComponent } from './AlertReusableComponent'; + +const mockQuery = vi.hoisted(() => ({ + useAddEntityToAlert: vi.fn(), + useAlertDefinitionByServiceTypeQuery: vi.fn(), + useRemoveEntityFromAlert: vi.fn(), +})); + +vi.mock('src/queries/cloudpulse/alerts', async () => { + const actual = vi.importActual('src/queries/cloudpulse/alerts'); + return { + ...actual, + useAddEntityToAlert: mockQuery.useAddEntityToAlert, + useAlertDefinitionByServiceTypeQuery: + mockQuery.useAlertDefinitionByServiceTypeQuery, + useRemoveEntityFromAlert: mockQuery.useRemoveEntityFromAlert, + }; +}); +const serviceType = 'linode'; +const entityId = '123'; +const entityName = 'test-instance'; +const alerts = [ + ...alertFactory.buildList(3, { service_type: serviceType }), + ...alertFactory.buildList(7, { + entity_ids: [entityId], + service_type: serviceType, + }), + ...alertFactory.buildList(1, { + entity_ids: [entityId], + service_type: serviceType, + status: 'enabled', + type: 'system', + }), +]; + +const mockReturnValue = { + data: alerts, + isError: false, + isLoading: false, +}; + +const component = ( + +); + +mockQuery.useAlertDefinitionByServiceTypeQuery.mockReturnValue(mockReturnValue); +mockQuery.useAddEntityToAlert.mockReturnValue({ + mutateAsync: vi.fn(), +}); +mockQuery.useRemoveEntityFromAlert.mockReturnValue({ + mutateAsync: vi.fn(), +}); + +const mockHistory = { + push: vi.fn(), + replace: vi.fn(), +}; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useHistory: vi.fn(() => mockHistory), + }; +}); + +describe('Alert Resuable Component for contextual view', () => { + it('Should go to alerts definition page on clicking manage alerts button', async () => { + const { getByTestId } = renderWithTheme(component); + await userEvent.click(getByTestId('manage-alerts')); + + expect(mockHistory.push).toHaveBeenCalledWith('/alerts/definitions'); + }); + + it('Should filter alerts based on search text', async () => { + const { getByPlaceholderText, getByText, queryByText } = renderWithTheme( + component + ); + await userEvent.type(getByPlaceholderText('Search for Alerts'), 'Alert-1'); + await waitFor(() => { + expect(getByText('Alert-1')).toBeVisible(); + expect(queryByText('Alert-3')).not.toBeInTheDocument(); + }); + }); + + it('Should filter alerts based on alert type', async () => { + const { getByRole, getByText } = renderWithTheme(component); + + await userEvent.click(getByRole('button', { name: 'Open' })); + + await userEvent.click(getByRole('option', { name: 'system' })); + + const alert = alerts[alerts.length - 1]; + expect(getByText(alert.label)).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx new file mode 100644 index 00000000000..7d40fe9b259 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.tsx @@ -0,0 +1,133 @@ +import { + Autocomplete, + Box, + Button, + CircleProgress, + Paper, + Stack, + Tooltip, + Typography, +} from '@linode/ui'; +import React from 'react'; +import { useHistory } from 'react-router-dom'; + +import InfoIcon from 'src/assets/icons/info.svg'; +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { useAlertDefinitionByServiceTypeQuery } from 'src/queries/cloudpulse/alerts'; + +import { AlertContextualViewTableHeaderMap } from '../AlertsListing/constants'; +import { + convertAlertsToTypeSet, + filterAlertsByStatusAndType, +} from '../Utils/utils'; +import { AlertInformationActionTable } from './AlertInformationActionTable'; + +import type { AlertDefinitionType } from '@linode/api-v4'; + +interface AlertReusableComponentProps { + /** + * Id for the selected entity + */ + entityId: string; + + /** + * Name of the selected entity + */ + entityName: string; + + /** + * Service type of selected entity + */ + serviceType: string; +} + +export const AlertReusableComponent = (props: AlertReusableComponentProps) => { + const { entityId, entityName, serviceType } = props; + const { + data: alerts, + error, + isLoading, + } = useAlertDefinitionByServiceTypeQuery(serviceType); + + const [searchText, setSearchText] = React.useState(''); + const [selectedType, setSelectedType] = React.useState< + AlertDefinitionType | undefined + >(); + + // Filter alerts based on serach text & selected type + const filteredAlerts = filterAlertsByStatusAndType( + alerts, + searchText, + selectedType + ); + + const history = useHistory(); + + // Filter unique alert types from alerts list + const types = convertAlertsToTypeSet(alerts); + + if (isLoading) { + return ; + } + return ( + + + + + Alerts + + + + + + + + + + + + { + setSelectedType(selectedValue?.label); + }} + textFieldProps={{ + hideLabel: true, + }} + autoHighlight + data-testid="alert-type-select" + label="Select Type" + noMarginTop + options={types} + placeholder="Select Alert Type" + sx={{ width: '250px' }} + /> + + + + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/ContextualView/MetricThreshold.tsx b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/MetricThreshold.tsx new file mode 100644 index 00000000000..5395d5fc7b1 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/MetricThreshold.tsx @@ -0,0 +1,73 @@ +import { Box, Chip, Tooltip, Typography } from '@linode/ui'; +import React from 'react'; + +import type { ProcessedCriteria } from '../Utils/utils'; + +export interface MetricThresholdProps { + /** + * List of processed criterias + */ + metricThreshold: ProcessedCriteria[]; +} + +export const MetricThreshold = (props: MetricThresholdProps) => { + const { metricThreshold } = props; + if (metricThreshold.length === 0) { + return ( + ({ + font: theme.tokens.alias.Typography.Label.Regular.S, + })} + > + - + + ); + } + + const thresholdObject = metricThreshold[0]; + const metric = `${thresholdObject.label} ${thresholdObject.metricOperator} ${thresholdObject.threshold} ${thresholdObject.unit}`; + const total = metricThreshold.length - 1; + if (metricThreshold.length === 1) { + return ( + ({ + font: theme.tokens.alias.Typography.Label.Regular.S, + })} + > + {metric} + + ); + } + const rest = metricThreshold + .slice(1) + .map((criteria) => { + return `${criteria.label} ${criteria.metricOperator} ${criteria.threshold} ${criteria.unit}`; + }) + .join('\n'); + return ( + + ({ + font: theme.tokens.alias.Typography.Label.Regular.S, + })} + > + {metric} + + {rest}} + > + + ({ + backgroundColor: theme.color.tagButtonBg, + color: theme.color.tagButtonText, + px: 0.5, + py: 1.5, + })} + label={`+${total}`} + /> + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx index f9fe7a0e0f6..559c5f49406 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.test.tsx @@ -10,8 +10,8 @@ vi.mock('src/queries/cloudpulse/resources', () => ({ useResourcesQuery: queryMocks.useResourcesQuery, })); -vi.mock('src/queries/regions/regions', () => ({ - ...vi.importActual('src/queries/regions/regions'), +vi.mock('@linode/queries', async (importOriginal) => ({ + ...(await importOriginal()), useRegionsQuery: queryMocks.useRegionsQuery, })); @@ -21,6 +21,7 @@ const queryMocks = vi.hoisted(() => ({ })); beforeEach(() => { + Element.prototype.scrollIntoView = vi.fn(); queryMocks.useResourcesQuery.mockReturnValue({ data: [], isError: false, @@ -79,9 +80,9 @@ describe('AlertDefinition Create', () => { await user.click( container.getByRole('button', { name: 'Add dimension filter' }) ); - const submitButton = container.getByText('Submit').closest('button'); + const submitButton = container.getByText('Submit'); await user.click(submitButton!); - expect(container.getAllByText('This field is required.').length).toBe(10); + expect(container.getAllByText('This field is required.').length).toBe(11); container.getAllByText(errorMessage).forEach((element) => { expect(element).toBeVisible(); }); @@ -101,5 +102,32 @@ describe('AlertDefinition Create', () => { expect( await container.findByText('The value should be a number.') ).toBeInTheDocument(); + + expect( + await container.findByText( + 'At least one notification channel is required.' + ) + ); + }); + + it('should validate the checks of Alert Name and Description', async () => { + const user = userEvent.setup(); + const container = renderWithTheme(); + const nameInput = container.getByLabelText('Name'); + const descriptionInput = container.getByLabelText('Description (optional)'); + await user.type(nameInput, '*#&+:<>"?@%'); + await user.type( + descriptionInput, + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + ); + await user.click(container.getByText('Submit')); + expect( + await container.findByText( + 'Name cannot contain special characters: * # & + : < > ? @ % { } \\ /.' + ) + ).toBeVisible(); + expect( + await container.findByText('Description must be 100 characters or less.') + ).toBeVisible(); }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index 4aa79c8d0d3..a36dee43e80 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -1,14 +1,18 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { Paper, TextField, Typography } from '@linode/ui'; +import { isEmpty } from '@linode/api-v4'; +import { ActionsPanel, Paper, TextField, Typography } from '@linode/ui'; +import { scrollErrorIntoView } from '@linode/utilities'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; import { useHistory } from 'react-router-dom'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { useFlags } from 'src/hooks/useFlags'; import { useCreateAlertDefinition } from 'src/queries/cloudpulse/alerts'; +import { enhanceValidationSchemaWithEntityIdValidation } from '../Utils/utils'; import { MetricCriteriaField } from './Criteria/MetricCriteria'; import { TriggerConditions } from './Criteria/TriggerConditions'; import { CloudPulseAlertSeveritySelect } from './GeneralInformation/AlertSeveritySelect'; @@ -56,28 +60,44 @@ const initialValues: CreateAlertDefinitionForm = { const overrides = [ { label: 'Definitions', - linkTo: '/monitor/alerts/definitions', + linkTo: '/alerts/definitions', position: 1, }, { label: 'Details', - linkTo: `/monitor/alerts/definitions/create`, + linkTo: `/alerts/definitions/create`, position: 2, }, ]; export const CreateAlertDefinition = () => { const history = useHistory(); - const alertCreateExit = () => history.push('/monitor/alerts/definitions'); + const alertCreateExit = () => history.push('/alerts/definitions'); + const flags = useFlags(); + const createAlertSchema = CreateAlertDefinitionFormSchema as ObjectSchema; + + // Default resolver + const [validationSchema, setValidationSchema] = React.useState( + enhanceValidationSchemaWithEntityIdValidation({ + aclpAlertServiceTypeConfig: flags.aclpAlertServiceTypeConfig ?? [], + baseSchema: createAlertSchema, + serviceTypeObj: null, + }) as ObjectSchema + ); const formMethods = useForm({ defaultValues: initialValues, mode: 'onBlur', - resolver: yupResolver( - CreateAlertDefinitionFormSchema as ObjectSchema - ), + resolver: yupResolver(validationSchema), }); + const { + control, + formState: { errors, isSubmitting, submitCount }, + getValues, + handleSubmit, + setError, + setValue, + } = formMethods; - const { control, formState, getValues, handleSubmit, setError } = formMethods; const { enqueueSnackbar } = useSnackbar(); const { mutateAsync: createAlert } = useCreateAlertDefinition( getValues('serviceType')! @@ -108,75 +128,112 @@ export const CreateAlertDefinition = () => { } }); + const previousSubmitCount = React.useRef(0); + React.useEffect(() => { + if (!isEmpty(errors) && submitCount > previousSubmitCount.current) { + scrollErrorIntoView(undefined, { behavior: 'smooth' }); + } + }, [errors, submitCount]); + + const handleServiceTypeChange = React.useCallback(() => { + // Reset the criteria to initial state + setValue('rule_criteria.rules', [ + { + aggregate_function: null, + dimension_filters: [], + metric: null, + operator: null, + threshold: 0, + }, + ]); + setValue('entity_ids', []); + }, [setValue]); + + React.useEffect(() => { + setValidationSchema( + enhanceValidationSchemaWithEntityIdValidation({ + aclpAlertServiceTypeConfig: flags.aclpAlertServiceTypeConfig ?? [], + baseSchema: createAlertSchema, + serviceTypeObj: serviceTypeWatcher, + }) as ObjectSchema + ); + }, [createAlertSchema, flags.aclpAlertServiceTypeConfig, serviceTypeWatcher]); + return ( - - - - - - 1. General Information - - ( - field.onChange(e.target.value)} - placeholder="Enter Name" - value={field.value ?? ''} - /> - )} - control={control} - name="label" - /> - ( - field.onChange(e.target.value)} - optional - placeholder="Enter Description" - value={field.value ?? ''} - /> - )} - control={control} - name="description" - /> - - - - - setMaxScrapeInterval(interval) - } - name="rule_criteria.rules" - serviceType={serviceTypeWatcher!} - /> - - - - - - + + + + + +
+ + 1. General Information + + ( + field.onChange(e.target.value)} + placeholder="Enter a Name" + value={field.value ?? ''} + /> + )} + control={control} + name="label" + /> + ( + field.onChange(e.target.value)} + optional + placeholder="Enter a Description" + value={field.value ?? ''} + /> + )} + control={control} + name="description" + /> + + + + + setMaxScrapeInterval(interval) + } + name="rule_criteria.rules" + serviceType={serviceTypeWatcher!} + /> + + + + +
+
+
); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.test.tsx index be3a7f59c47..97e9eb9d0b7 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.test.tsx @@ -6,7 +6,7 @@ import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { DimensionFilters } from './DimensionFilter'; -import type { CreateAlertDefinitionForm } from '../types'; +import type { CreateAlertDefinitionForm, DimensionFilterForm } from '../types'; import type { MetricDefinition } from '@linode/api-v4'; const mockData: MetricDefinition[] = [ @@ -140,4 +140,45 @@ describe('DimensionFilterField', () => { expect(container.queryByTestId(dimensionFilterID)).not.toBeInTheDocument() ); }); + it('should show tooltip when the max limit of dimension filters is reached', async () => { + const dimensionFilterValue: DimensionFilterForm = { + dimension_label: 'state', + operator: 'eq', + value: 'free', + }; + const { + getByText, + } = renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [ + { + ...mockData[0], + dimension_filters: Array(5).fill(dimensionFilterValue), + }, + ], + }, + serviceType: 'linode', + }, + }, + }); + const addButton = screen.getByRole('button', { + name: dimensionFilterButton, + }); + expect(addButton).toBeDisabled(); + userEvent.hover(addButton); + await waitFor(() => + expect( + getByText('You can add up to 5 dimension filters.') + ).toBeInTheDocument() + ); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx index a9a05303102..75a0343caf6 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx @@ -1,7 +1,7 @@ import { Box } from '@linode/ui'; import { Button, Stack, Typography } from '@linode/ui'; import React from 'react'; -import { useFieldArray, useFormContext } from 'react-hook-form'; +import { useFieldArray, useFormContext, useWatch } from 'react-hook-form'; import { DimensionFilterField } from './DimensionFilterField'; @@ -31,6 +31,8 @@ export const DimensionFilters = (props: DimensionFilterProps) => { control, name, }); + + const dimensionFilterWatcher = useWatch({ control, name }); return ( @@ -58,10 +60,12 @@ export const DimensionFilters = (props: DimensionFilterProps) => { value: null, }) } - buttonType="secondary" compactX + data-qa-buttons="true" + disabled={dimensionFilterWatcher && dimensionFilterWatcher.length === 5} size="small" - sx={{ justifyContent: 'start' }} + sx={{ justifyContent: 'start', width: '160px' }} + tooltipText="You can add up to 5 dimension filters." > Add dimension filter diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx index 966683259e2..97de4cdeef2 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.test.tsx @@ -1,8 +1,8 @@ +import { capitalize } from '@linode/utilities'; import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { capitalize } from 'src/utilities/capitalize'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { dimensionOperatorOptions } from '../../constants'; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx index 9d44b5a760c..0b5cb20fa1e 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilterField.tsx @@ -1,10 +1,9 @@ import { Autocomplete, Box } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import { Grid } from '@mui/material'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; -import { capitalize } from 'src/utilities/capitalize'; - import { dimensionOperatorOptions } from '../../constants'; import { ClearIconButton } from './ClearIconButton'; @@ -86,7 +85,13 @@ export const DimensionFilterField = (props: DimensionFilterFieldProps) => { }; return ( - + ( @@ -103,13 +108,14 @@ export const DimensionFilterField = (props: DimensionFilterFieldProps) => { (option) => option.value === field.value ) ?? null } + data-qa-dimension-filter={`${name}-data-field`} data-testid="data-field" disabled={dataFieldDisabled} errorText={fieldState.error?.message} label="Data Field" onBlur={field.onBlur} options={dataFieldOptions} - placeholder="Select a Data field" + placeholder="Select a Data Field" /> )} control={control} @@ -134,12 +140,14 @@ export const DimensionFilterField = (props: DimensionFilterFieldProps) => { (option) => option.value === field.value ) ?? null } + data-qa-dimension-filter={`${name}-operator`} data-testid="operator" disabled={!dimensionFieldWatcher} errorText={fieldState.error?.message} label="Operator" onBlur={field.onBlur} options={dimensionOperatorOptions} + placeholder="Select an Operator" /> )} control={control} @@ -168,6 +176,7 @@ export const DimensionFilterField = (props: DimensionFilterFieldProps) => { (option) => option.value === field.value ) ?? null } + data-qa-dimension-filter={`${name}-value`} data-testid="value" disabled={!dimensionFieldWatcher} errorText={fieldState.error?.message} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx index 95a825d6184..3c51d779c7a 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.test.tsx @@ -196,7 +196,7 @@ describe('Metric component tests', () => { expect( await container.findByRole('option', { name: '>' }) ).toBeInTheDocument(); - expect(container.getByRole('option', { name: '==' })).toBeInTheDocument(); + expect(container.getByRole('option', { name: '=' })).toBeInTheDocument(); expect(container.getByRole('option', { name: '<' })).toBeInTheDocument(); const option = await container.findByRole('option', { name: '>' }); await user.click(option); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx index c5fabddffaa..c8ff0e0058c 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx @@ -130,7 +130,7 @@ export const Metric = (props: MetricCriteriaProps) => { - + ( { (option) => option.value === field.value ) ?? null } + data-qa-metric-threshold={`${name}-data-field`} data-testid="data-field" disabled={!serviceWatcher} label="Data Field" @@ -174,7 +175,7 @@ export const Metric = (props: MetricCriteriaProps) => { name={`${name}.metric`} /> - + ( { aggOptions.find((option) => option.value === field.value) ?? null } + data-qa-metric-threshold={`${name}-aggregation-type`} data-testid="aggregation-type" disabled={aggOptions.length === 0} errorText={fieldState.error?.message} @@ -207,7 +209,7 @@ export const Metric = (props: MetricCriteriaProps) => { name={`${name}.aggregate_function`} /> - + ( { ) : null } + data-qa-metric-threshold={`${name}-operator`} data-testid="operator" disabled={!metricWatcher} errorText={fieldState.error?.message} @@ -243,14 +246,22 @@ export const Metric = (props: MetricCriteriaProps) => { name={`${name}.operator`} /> - + ( ) => event.target instanceof HTMLElement && event.target.blur() } + sx={{ + height: '34px', + }} + data-qa-metric-threshold={`${name}-threshold`} + data-qa-threshold="threshold" data-testid="threshold" errorText={fieldState.error?.message} label="Threshold" @@ -259,7 +270,6 @@ export const Metric = (props: MetricCriteriaProps) => { noMarginTop onBlur={field.onBlur} onChange={(e) => field.onChange(e.target.value)} - sx={{ height: '34px', marginTop: { sm: 1, xs: 0 } }} type="number" value={field.value ?? 0} /> @@ -272,6 +282,7 @@ export const Metric = (props: MetricCriteriaProps) => { alignItems: 'flex-end', display: 'flex', height: '56px', + marginTop: { lg: '5px', md: '5px', sm: '5px' }, }} variant="body1" > diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.test.tsx index 4efd8ae0a29..4608d2eb10c 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.test.tsx @@ -245,4 +245,40 @@ describe('MetricCriteriaField', () => { expect(setMaxInterval).toBeCalledWith(firstOptionConvertedTime); }); + it('displays tooltip when the button is disabled', async () => { + const { + getByText, + } = renderWithThemeAndHookFormContext({ + component: ( + + ), + useFormOptions: { + defaultValues: { + rule_criteria: { + rules: [ + mockData.data[0], + mockData.data[0], + mockData.data[1], + mockData.data[1], + mockData.data[0], + ], + }, + }, + }, + }); + + const addButton = screen.getByRole('button', { + name: 'Add metric', + }); + + expect(addButton).toBeDisabled(); + userEvent.hover(addButton); + await waitFor(() => + expect(getByText('You can add up to 5 metrics.')).toBeInTheDocument() + ); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx index 601a1d20315..db0e92292ed 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/MetricCriteria.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Stack, Typography } from '@linode/ui'; +import { Button, Stack, Typography } from '@linode/ui'; import * as React from 'react'; import { useFieldArray, useFormContext, useWatch } from 'react-hook-form'; @@ -64,17 +64,11 @@ export const MetricCriteriaField = (props: MetricCriteriaProps) => { control, name, }); + return ( - ({ marginTop: theme.spacing(3) })}> - - 3. Criteria - - ({ marginTop: theme.spacing(3) })}> + + 3. Criteria + {fields !== null && fields.length !== 0 && fields.map((field, index) => { @@ -101,12 +95,16 @@ export const MetricCriteriaField = (props: MetricCriteriaProps) => { threshold: 0, }) } + sx={{ + width: '130px', // added a nice width for the button + }} buttonType="outlined" + disabled={metricCriteriaWatcher.length === 5} size="medium" - sx={(theme) => ({ marginTop: theme.spacing(2) })} + tooltipText="You can add up to 5 metrics." > Add metric - +
); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx index f9c58068090..ec0ce1b1592 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.test.tsx @@ -53,7 +53,8 @@ describe('Trigger Conditions', () => { const evaluationPeriodToolTip = within(evaluationPeriodContainer).getByRole( 'button', { - name: 'Choose how often you intend to evaluate the alert condition.', + name: + 'Defines the timeframe for collecting data in polling intervals to understand the service performance. Choose the data lookback period where the thresholds are applied to gather the information impactful for your business.', } ); const pollingIntervalContainer = container.getByTestId( @@ -62,8 +63,7 @@ describe('Trigger Conditions', () => { const pollingIntervalToolTip = within(pollingIntervalContainer).getByRole( 'button', { - name: - 'Defines the timeframe for collecting data in polling intervals to understand the service performance. Choose the data lookback period where the thresholds are applied to gather the information impactful for your business.', + name: 'Choose how often you intend to evaluate the alert condition.', } ); expect(evaluationPeriodToolTip).toBeInTheDocument(); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.tsx index 5c4c5ed381e..b5804a79dc0 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/TriggerConditions.tsx @@ -57,7 +57,13 @@ export const TriggerConditions = (props: TriggerConditionProps) => { })} > Trigger Conditions - + ( @@ -73,7 +79,7 @@ export const TriggerConditions = (props: TriggerConditionProps) => { }} textFieldProps={{ labelTooltipText: - 'Choose how often you intend to evaluate the alert condition.', + 'Defines the timeframe for collecting data in polling intervals to understand the service performance. Choose the data lookback period where the thresholds are applied to gather the information impactful for your business.', }} value={ getEvaluationPeriodOptions().find( @@ -108,7 +114,7 @@ export const TriggerConditions = (props: TriggerConditionProps) => { }} textFieldProps={{ labelTooltipText: - 'Defines the timeframe for collecting data in polling intervals to understand the service performance. Choose the data lookback period where the thresholds are applied to gather the information impactful for your business.', + 'Choose how often you intend to evaluate the alert condition.', }} value={ getPollingIntervalOptions().find( @@ -121,7 +127,7 @@ export const TriggerConditions = (props: TriggerConditionProps) => { label="Polling Interval" onBlur={field.onBlur} options={getPollingIntervalOptions()} - placeholder="Select a Polling" + placeholder="Select a Polling Interval" /> )} control={control} @@ -129,34 +135,49 @@ export const TriggerConditions = (props: TriggerConditionProps) => { /> - + Trigger alert when all criteria are met for ( event.target instanceof HTMLElement && event.target.blur() } sx={{ - height: '30px', - width: '30px', + height: '34px', + marginTop: { sm: '16px', xs: '0px' }, + width: '100px', }} + data-qa-trigger-occurrences data-testid="trigger-occurences" errorText={fieldState.error?.message} label="" min={0} name={`${name}.trigger_occurrences`} + noMarginTop onBlur={field.onBlur} onChange={(e) => field.onChange(e.target.value)} type="number" @@ -167,7 +188,12 @@ export const TriggerConditions = (props: TriggerConditionProps) => { name={`${name}.trigger_occurrences`} /> - + consecutive occurrence(s). diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.test.tsx index 805b134507b..fb0368f09ed 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.test.tsx @@ -1,6 +1,6 @@ +import * as queries from '@linode/queries'; import * as React from 'react'; -import * as regions from 'src/queries/regions/regions'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { CloudPulseRegionSelect } from './RegionSelect'; @@ -8,9 +8,9 @@ import { CloudPulseRegionSelect } from './RegionSelect'; import type { Region } from '@linode/api-v4'; describe('RegionSelect', () => { - vi.spyOn(regions, 'useRegionsQuery').mockReturnValue({ + vi.spyOn(queries, 'useRegionsQuery').mockReturnValue({ data: Array(), - } as ReturnType); + } as ReturnType); it('should render a RegionSelect component', () => { const { getByTestId } = renderWithThemeAndHookFormContext({ @@ -19,11 +19,11 @@ describe('RegionSelect', () => { expect(getByTestId('region-select')).toBeInTheDocument(); }); it('should render a Region Select component with proper error message on api call failure', () => { - vi.spyOn(regions, 'useRegionsQuery').mockReturnValue({ + vi.spyOn(queries, 'useRegionsQuery').mockReturnValue({ data: undefined, isError: true, isLoading: false, - } as ReturnType); + } as ReturnType); const { getByText } = renderWithThemeAndHookFormContext({ component: , }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.tsx index fba152700a6..ed607e593c0 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/RegionSelect.tsx @@ -1,8 +1,8 @@ +import { useRegionsQuery } from '@linode/queries'; import * as React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { useRegionsQuery } from 'src/queries/regions/regions'; import type { CreateAlertDefinitionForm } from '../types'; import type { FieldPathByValue } from 'react-hook-form'; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx index 42e3f9e1e68..2ec5e4c20b6 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.test.tsx @@ -41,7 +41,9 @@ queryMocks.useCloudPulseServiceTypes.mockReturnValue({ describe('ServiceTypeSelect component tests', () => { it('should render the Autocomplete component', () => { const { getAllByText, getByTestId } = renderWithThemeAndHookFormContext({ - component: , + component: ( + + ), }); expect(getByTestId('servicetype-select')).toBeInTheDocument(); getAllByText('Service'); @@ -49,7 +51,9 @@ describe('ServiceTypeSelect component tests', () => { it('should render service types happy path', async () => { renderWithThemeAndHookFormContext({ - component: , + component: ( + + ), }); userEvent.click(screen.getByRole('button', { name: 'Open' })); expect( @@ -66,7 +70,9 @@ describe('ServiceTypeSelect component tests', () => { it('should be able to select a service type', async () => { renderWithThemeAndHookFormContext({ - component: , + component: ( + + ), }); userEvent.click(screen.getByRole('button', { name: 'Open' })); await userEvent.click( @@ -81,7 +87,9 @@ describe('ServiceTypeSelect component tests', () => { isLoading: false, }); renderWithThemeAndHookFormContext({ - component: , + component: ( + + ), }); expect( screen.getByText('Failed to fetch the service types.') diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx index 8df9ba4851d..a8716cb0b6f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/GeneralInformation/ServiceTypeSelect.tsx @@ -10,6 +10,15 @@ import type { AlertServiceType } from '@linode/api-v4'; import type { FieldPathByValue } from 'react-hook-form'; interface CloudPulseServiceSelectProps { + /** + * @returns vsoid + * function to handle the service type change + */ + handleServiceTypeChange?: () => void; + /** + * Boolean value to check if service select is disabled in the edit flow + */ + isDisabled?: boolean; /** * name used for the component in the form */ @@ -19,7 +28,7 @@ interface CloudPulseServiceSelectProps { export const CloudPulseServiceSelect = ( props: CloudPulseServiceSelectProps ) => { - const { name } = props; + const { handleServiceTypeChange, isDisabled, name } = props; const { data: serviceOptions, error: serviceTypesError, @@ -58,13 +67,16 @@ export const CloudPulseServiceSelect = ( if (reason === 'clear') { field.onChange(null); } + if (handleServiceTypeChange !== undefined) { + handleServiceTypeChange(); + } }} value={ - field.value !== null - ? getServicesList.find((option) => option.value === field.value) - : null + getServicesList.find((option) => option.value === field.value) ?? + null } data-testid="servicetype-select" + disabled={isDisabled} fullWidth label="Service" loading={serviceTypesLoading && !serviceTypesError} diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.test.tsx index cb4bc6249a4..e0b652f88b5 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.test.tsx @@ -1,9 +1,9 @@ -import { within } from '@testing-library/react'; +import { capitalize } from '@linode/utilities'; +import { waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; import { notificationChannelFactory } from 'src/factories/cloudpulse/channels'; -import { capitalize } from 'src/utilities/capitalize'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { AddChannelListing } from './AddChannelListing'; @@ -79,4 +79,31 @@ describe('Channel Listing component', () => { expect(notificationContainer).not.toBeInTheDocument(); }); + it('should show tooltip when the max limit of notification channels is reached', async () => { + // Mock the `notificationChannelWatcher` length to simulate the max limit + const mockMaxLimit = 5; + const { + getByRole, + getByText, + } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + channel_ids: Array(mockMaxLimit).fill(mockNotificationData[0].id), // simulate 5 channels + }, + }, + }); + + const addButton = getByRole('button', { + name: 'Add notification channel', + }); + + expect(addButton).toBeDisabled(); + userEvent.hover(addButton); + await waitFor(() => + expect( + getByText('You can add up to 5 notification channels.') + ).toBeInTheDocument() + ); + }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx index 9345c44a2fa..16569bd8243 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddChannelListing.tsx @@ -1,9 +1,9 @@ -import { Box, Button, Stack, Typography } from '@linode/ui'; +import { Box, Button, Notice, Stack, Typography } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import React from 'react'; -import { useFormContext, useWatch } from 'react-hook-form'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { useAllAlertNotificationChannelsQuery } from 'src/queries/cloudpulse/alerts'; -import { capitalize } from 'src/utilities/capitalize'; import { channelTypeOptions } from '../../constants'; import { getAlertBoxStyles } from '../../Utils/utils'; @@ -32,7 +32,7 @@ interface NotificationChannelsProps { */ notification: NotificationChannel; } -export const AddChannelListing = React.memo((props: AddChannelListingProps) => { +export const AddChannelListing = (props: AddChannelListingProps) => { const { name } = props; const { control, setValue } = useFormContext(); const [openAddNotification, setOpenAddNotification] = React.useState(false); @@ -67,7 +67,7 @@ export const AddChannelListing = React.memo((props: AddChannelListingProps) => { const handleRemove = (index: number) => { const newList = notificationChannelWatcher.filter((_, i) => i !== index); - setValue(name, newList); + setValue(name, newList, { shouldValidate: true }); }; const handleOpenDrawer = () => { @@ -79,7 +79,9 @@ export const AddChannelListing = React.memo((props: AddChannelListingProps) => { }; const handleAddNotification = (notificationId: number) => { - setValue(name, [...notificationChannelWatcher, notificationId]); + setValue(name, [...notificationChannelWatcher, notificationId], { + shouldValidate: true, + }); handleCloseDrawer(); }; @@ -94,20 +96,21 @@ export const AddChannelListing = React.memo((props: AddChannelListingProps) => { overflow: 'auto', padding: theme.spacing(2), })} + data-qa-notification={`notification-channel-${id}`} data-testid={`notification-channel-${id}`} key={id} > - + {capitalize(notification?.label ?? 'Unnamed Channel')} handleRemove(id)} /> - + Type: - + { channelTypeOptions.find( (option) => option.value === notification?.channel_type @@ -116,10 +119,10 @@ export const AddChannelListing = React.memo((props: AddChannelListingProps) => { - + To: - + {notification && } @@ -135,38 +138,51 @@ export const AddChannelListing = React.memo((props: AddChannelListingProps) => { ); return ( - <> - - 4. Notification Channels - - - {selectedNotifications.length > 0 && - selectedNotifications.map((notification, id) => ( - - ))} - - + ( + <> + + 4. Notification Channels + + {(formState.isSubmitted || fieldState.isTouched) && fieldState.error && ( + + {fieldState.error.message} + + )} + + {selectedNotifications.length > 0 && + selectedNotifications.map((notification, id) => ( + + ))} + + - - + + + )} + control={control} + name={name} + /> ); -}); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannelDrawer.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannelDrawer.tsx index dc250668a43..fc6b26c3fa1 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannelDrawer.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/NotificationChannels/AddNotificationChannelDrawer.tsx @@ -1,11 +1,16 @@ import { yupResolver } from '@hookform/resolvers/yup'; -import { Autocomplete, Box, Typography } from '@linode/ui'; +import { + ActionsPanel, + Autocomplete, + Box, + Drawer, + Typography, +} from '@linode/ui'; import Grid from '@mui/material/Grid'; import React from 'react'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; +import { NotFound } from 'src/components/NotFound'; import { channelTypeOptions } from '../../constants'; import { getAlertBoxStyles } from '../../Utils/utils'; @@ -90,11 +95,17 @@ export const AddNotificationChannelDrawer = ( (template) => template.label === channelLabelWatcher ); + const resetDrawer = () => { + handleCloseDrawer(); + reset(); + }; + return (
@@ -108,7 +119,7 @@ export const AddNotificationChannelDrawer = ( > ({ - color: theme.tokens.content.Text, + color: theme.tokens.alias.Content.Text, })} gutterBottom variant="h3" @@ -164,6 +175,11 @@ export const AddNotificationChannelDrawer = ( reason === 'selectOption' ? selected.label : null ); }} + slotProps={{ + popper: { + placement: 'bottom', + }, + }} value={ selectedChannelTypeTemplate?.find( (option) => option.label === field.value @@ -189,7 +205,15 @@ export const AddNotificationChannelDrawer = ( To: - + @@ -204,7 +228,7 @@ export const AddNotificationChannelDrawer = ( }} secondaryButtonProps={{ label: 'Cancel', - onClick: handleCloseDrawer, + onClick: resetDrawer, }} /> diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Resources/CloudPulseModifyAlertResources.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Resources/CloudPulseModifyAlertResources.test.tsx index c5396a433e0..d7d2fcb985b 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Resources/CloudPulseModifyAlertResources.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Resources/CloudPulseModifyAlertResources.test.tsx @@ -42,8 +42,8 @@ vi.mock('src/queries/cloudpulse/resources', () => ({ useResourcesQuery: queryMocks.useResourcesQuery, })); -vi.mock('src/queries/regions/regions', () => ({ - ...vi.importActual('src/queries/regions/regions'), +vi.mock('@linode/queries', async (importOriginal) => ({ + ...(await importOriginal()), useRegionsQuery: queryMocks.useRegionsQuery, })); @@ -77,6 +77,7 @@ describe('CreateAlertResources component tests', () => { const { getByPlaceholderText, getByTestId, + queryByTestId, } = renderWithThemeAndHookFormContext({ component: , useFormOptions: { @@ -134,5 +135,41 @@ describe('CreateAlertResources component tests', () => { checkedAttribute, 'false' ); + // no error notice should be there in happy path + expect(queryByTestId('alert_message_notice')).not.toBeInTheDocument(); + }); + + it('should be able to see the error notice if the forms field state has error', () => { + const { + getAllByTestId, + getByText, + } = renderWithThemeAndHookFormContext({ + component: , + options: { + flags: { + aclpAlertServiceTypeConfig: [ + { + maxResourceSelectionCount: 2, + serviceType: 'linode', + }, + ], + }, + }, + useFormOptions: { + defaultValues: { + entity_ids: ['1', '2', '3'], + serviceType: 'linode', + }, + errors: { + entity_ids: { + message: 'More than 2 resources selected', + }, + }, + }, + }); + + expect(getAllByTestId('alert_message_notice').length).toBe(2); // one for error and one for selection warning + expect(getByText('You can select up to 2 resources.')).toBeInTheDocument(); + expect(getByText('More than 2 resources selected')).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Resources/CloudPulseModifyAlertResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Resources/CloudPulseModifyAlertResources.tsx index 2db9917b8b0..0aebd8b6419 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Resources/CloudPulseModifyAlertResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Resources/CloudPulseModifyAlertResources.tsx @@ -2,6 +2,8 @@ import { Box, Typography } from '@linode/ui'; import React from 'react'; import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { useFlags } from 'src/hooks/useFlags'; + import { AlertResources } from '../../AlertsResources/AlertsResources'; import { getAlertBoxStyles } from '../../Utils/utils'; @@ -21,6 +23,19 @@ export const CloudPulseModifyAlertResources = React.memo( const { control, setValue } = useFormContext(); const serviceTypeWatcher = useWatch({ control, name: 'serviceType' }); + const flags = useFlags(); + + const maxSelectionCount = React.useMemo(() => { + if (!serviceTypeWatcher || !flags.aclpAlertServiceTypeConfig) { + return undefined; + } + + return flags.aclpAlertServiceTypeConfig?.find( + (config) => + config.serviceType && config.serviceType === serviceTypeWatcher + )?.maxResourceSelectionCount; + }, [flags.aclpAlertServiceTypeConfig, serviceTypeWatcher]); + const handleResourcesSelection = (resourceIds: string[]) => { setValue(name, resourceIds, { shouldTouch: true, @@ -28,15 +43,11 @@ export const CloudPulseModifyAlertResources = React.memo( }); }; - React.useEffect(() => { - setValue(name, [], { shouldValidate: true }); - }, [name, serviceTypeWatcher, setValue]); - const titleRef = React.useRef(null); return ( ( + render={({ field, fieldState }) => ( 2. Resources @@ -50,9 +61,11 @@ export const CloudPulseModifyAlertResources = React.memo( diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx new file mode 100644 index 00000000000..52cf44506e9 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.test.tsx @@ -0,0 +1,113 @@ +import { waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createMemoryHistory } from 'history'; +import React from 'react'; +import { Router } from 'react-router-dom'; + +import { alertFactory, notificationChannelFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { EditAlertDefinition } from './EditAlertDefinition'; + +const queryMocks = vi.hoisted(() => ({ + useAllAlertNotificationChannelsQuery: vi.fn(), + useEditAlertDefinition: vi.fn(), +})); + +vi.mock('src/queries/cloudpulse/alerts', () => ({ + ...vi.importActual('src/queries/cloudpulse/alerts'), + useAllAlertNotificationChannelsQuery: + queryMocks.useAllAlertNotificationChannelsQuery, + useEditAlertDefinition: queryMocks.useEditAlertDefinition, +})); + +beforeEach(() => { + vi.clearAllMocks(); + Element.prototype.scrollIntoView = vi.fn(); + queryMocks.useEditAlertDefinition.mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue({}), + reset: vi.fn(), + }); + queryMocks.useAllAlertNotificationChannelsQuery.mockReturnValue( + notificationChannelFactory.build() + ); +}); + +const alertDetails = alertFactory.build({ + id: 1, + service_type: 'linode', +}); +describe('EditAlertDefinition component', () => { + it( + 'renders the components of the form', + async () => { + const { + findByPlaceholderText, + getByLabelText, + getByText, + } = renderWithTheme( + + ); + expect(getByText('1. General Information')).toBeVisible(); + expect(getByLabelText('Name')).toBeVisible(); + expect(getByLabelText('Description (optional)')).toBeVisible(); + expect(getByLabelText('Severity')).toBeVisible(); + expect(getByLabelText('Service')).toBeVisible(); + expect(getByText('2. Resources')).toBeVisible(); + expect( + await findByPlaceholderText('Search for a Region or Resource') + ).toBeInTheDocument(); + expect(await findByPlaceholderText('Select Regions')).toBeInTheDocument(); + expect(getByText('3. Criteria')).toBeVisible(); + expect(getByText('Metric Threshold')).toBeVisible(); + expect(getByLabelText('Data Field')).toBeVisible(); + expect(getByLabelText('Aggregation Type')).toBeVisible(); + expect(getByLabelText('Operator')).toBeVisible(); + expect(getByLabelText('Threshold')).toBeVisible(); + expect(getByLabelText('Evaluation Period')).toBeVisible(); + expect(getByLabelText('Polling Interval')).toBeVisible(); + expect(getByText('4. Notification Channels')).toBeVisible(); + }, + { timeout: 20000 } + ); + + it( + 'should submit form data correctly', + async () => { + const push = vi.fn(); + const history = createMemoryHistory(); + history.push = push; + history.push('/alerts/definitions/edit/linode/1'); + const mutateAsyncSpy = queryMocks.useEditAlertDefinition().mutateAsync; + const { getByPlaceholderText, getByText } = renderWithTheme( + + + + ); + const descriptionValue = 'Updated Description'; + const nameValue = 'Updated Label'; + const nameInput = getByPlaceholderText('Enter a Name'); + const descriptionInput = getByPlaceholderText('Enter a Description'); + await userEvent.clear(nameInput); + await userEvent.clear(descriptionInput); + await userEvent.type(nameInput, nameValue); + + await userEvent.type(descriptionInput, descriptionValue); + + await userEvent.click(getByText('Submit')); + + await waitFor(() => expect(mutateAsyncSpy).toHaveBeenCalledTimes(1)); + + expect(push).toHaveBeenLastCalledWith('/alerts/definitions'); + await waitFor(() => { + expect( + getByText('Alert successfully updated.') // validate whether snackbar is displayed properly + ).toBeInTheDocument(); + }); + }, + { timeout: 10000 } + ); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx new file mode 100644 index 00000000000..36a83a36d26 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertDefinition.tsx @@ -0,0 +1,187 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { isEmpty } from '@linode/api-v4'; +import { ActionsPanel, Paper, TextField, Typography } from '@linode/ui'; +import { scrollErrorIntoView } from '@linode/utilities'; +import { useSnackbar } from 'notistack'; +import React from 'react'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; +import { useHistory } from 'react-router-dom'; + +import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; +import { useFlags } from 'src/hooks/useFlags'; +import { useEditAlertDefinition } from 'src/queries/cloudpulse/alerts'; + +import { MetricCriteriaField } from '../CreateAlert/Criteria/MetricCriteria'; +import { TriggerConditions } from '../CreateAlert/Criteria/TriggerConditions'; +import { CloudPulseAlertSeveritySelect } from '../CreateAlert/GeneralInformation/AlertSeveritySelect'; +import { CloudPulseServiceSelect } from '../CreateAlert/GeneralInformation/ServiceTypeSelect'; +import { AddChannelListing } from '../CreateAlert/NotificationChannels/AddChannelListing'; +import { CloudPulseModifyAlertResources } from '../CreateAlert/Resources/CloudPulseModifyAlertResources'; +import { + convertAlertDefinitionValues, + enhanceValidationSchemaWithEntityIdValidation, +} from '../Utils/utils'; +import { EditAlertDefinitionFormSchema } from './schemas'; + +import type { + Alert, + AlertServiceType, + EditAlertDefinitionPayload, +} from '@linode/api-v4'; +import type { ObjectSchema } from 'yup'; + +export interface EditAlertProps { + /** + * The details of the alert being edited. + */ + alertDetails: Alert; + /** + * The type of service associated with the alert + */ + serviceType: AlertServiceType; +} + +export const EditAlertDefinition = (props: EditAlertProps) => { + const { alertDetails, serviceType } = props; + const history = useHistory(); + + const { enqueueSnackbar } = useSnackbar(); + + const filteredAlertDefinitionValues = convertAlertDefinitionValues( + alertDetails, + serviceType + ); + const flags = useFlags(); + const editAlertSchema = EditAlertDefinitionFormSchema as ObjectSchema; + const formMethods = useForm({ + defaultValues: filteredAlertDefinitionValues, + mode: 'onBlur', + resolver: yupResolver( + enhanceValidationSchemaWithEntityIdValidation({ + aclpAlertServiceTypeConfig: flags.aclpAlertServiceTypeConfig ?? [], + baseSchema: editAlertSchema, + serviceTypeObj: alertDetails.service_type, + }) as ObjectSchema + ), + }); + + const alertId = alertDetails.id; + const { mutateAsync: editAlert } = useEditAlertDefinition(); + const { control, formState, handleSubmit, setError } = formMethods; + const [maxScrapeInterval, setMaxScrapeInterval] = React.useState(0); + + const onSubmit = handleSubmit(async (values) => { + try { + await editAlert({ alertId, serviceType, ...values }); + enqueueSnackbar('Alert successfully updated.', { + variant: 'success', + }); + history.push(definitionLanding); + } catch (errors) { + for (const error of errors) { + if (error.field) { + setError(error.field, { message: error.reason }); + } else { + enqueueSnackbar(`Alert update failed: ${error.reason}`, { + variant: 'error', + }); + setError('root', { message: error.reason }); + } + } + } + }); + const definitionLanding = '/alerts/definitions'; + + const overrides = [ + { + label: 'Definitions', + linkTo: definitionLanding, + position: 1, + }, + { + label: 'Edit', + linkTo: `${definitionLanding}/edit/${serviceType}/${alertId}`, + position: 2, + }, + ]; + + const previousSubmitCount = React.useRef(0); + React.useEffect(() => { + if ( + !isEmpty(formState.errors) && + formState.submitCount > previousSubmitCount.current + ) { + scrollErrorIntoView(undefined, { behavior: 'smooth' }); + } + }, [formState.errors, formState.submitCount]); + + return ( + + + +
+ + 1. General Information + + ( + field.onChange(e.target.value)} + placeholder="Enter a Name" + value={field.value ?? ''} + /> + )} + control={control} + name="label" + /> + ( + field.onChange(e.target.value)} + optional + placeholder="Enter a Description" + value={field.value ?? ''} + /> + )} + control={control} + name="description" + /> + + + + + + + history.push(definitionLanding), + }} + sx={{ display: 'flex', justifyContent: 'flex-end' }} + /> + +
+
+ ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertLanding.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertLanding.test.tsx new file mode 100644 index 00000000000..5a3bce99f8c --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertLanding.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { EditAlertLanding } from './EditAlertLanding'; + +const queryMocks = vi.hoisted(() => ({ + useAlertDefinitionQuery: vi.fn(), +})); + +vi.mock('src/queries/cloudpulse/alerts', () => ({ + ...vi.importActual('src/queries/cloudpulse/alerts'), + useAlertDefinitionQuery: queryMocks.useAlertDefinitionQuery, +})); + +describe('Edit Alert Landing tests', () => { + it('Edit alert resources alert details error and loading path', () => { + queryMocks.useAlertDefinitionQuery.mockReturnValue({ + data: undefined, + isError: true, // simulate error + isFetching: false, + }); + + const { getByText } = renderWithTheme(); + + expect( + getByText( + 'An error occurred while loading the alerts definitions and resources. Please try again later.' + ) + ).toBeInTheDocument(); + + queryMocks.useAlertDefinitionQuery.mockReturnValue({ + data: undefined, + isError: false, + isFetching: true, // simulate loading + }); + + const { getByTestId } = renderWithTheme(); + + expect(getByTestId('circle-progress')).toBeInTheDocument(); + }); + + it('Edit alert resources alert details empty path', () => { + queryMocks.useAlertDefinitionQuery.mockReturnValue({ + data: undefined, // simulate empty + isError: false, + isFetching: false, + }); + + const { getByText } = renderWithTheme(); + + expect(getByText('No Data to display.')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertLanding.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertLanding.tsx new file mode 100644 index 00000000000..c2e8aa8b9cc --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertLanding.tsx @@ -0,0 +1,102 @@ +import { Box, CircleProgress, ErrorState } from '@linode/ui'; +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import EntityIcon from 'src/assets/icons/entityIcons/alerts.svg'; +import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; +import { useAlertDefinitionQuery } from 'src/queries/cloudpulse/alerts'; + +import { StyledPlaceholder } from '../AlertsDetail/AlertDetail'; +import { EditAlertDefinition } from './EditAlertDefinition'; +import { EditAlertResources } from './EditAlertResources'; + +import type { AlertRouteParams } from '../AlertsDetail/AlertDetail'; +import type { AlertServiceType } from '@linode/api-v4'; +import type { CrumbOverridesProps } from 'src/components/Breadcrumb/Crumbs'; + +const overrides = [ + { + label: 'Definitions', + linkTo: '/alerts/definitions', + position: 1, + }, + { + label: 'Edit', + linkTo: `/alerts/definitions/edit`, + position: 2, + }, +]; + +export const EditAlertLanding = () => { + const { alertId, serviceType } = useParams(); + const { data: alertDetails, isError, isFetching } = useAlertDefinitionQuery( + alertId, + serviceType + ); + const pathname = '/Definition/Edit'; + + if (isFetching) { + return ( + + + + ); + } + + if (isError) { + return ( + + + + ); + } + + if (!alertDetails) { + return ( + + + + ); + } + + if (alertDetails.type === 'system') { + return ( + + ); + } else { + return ( + + ); + } +}; + +/** + * A component that renders a common UI structure for loading, error, or empty states. + * @param pathname - The current pathname to be provided in breadcrumb + * @param crumbOverrides - The overrides to be provided in breadcrumb + * @param children - The message component (e.g., CircleProgress, ErrorState, or Placeholder) + */ +const EditAlertLoadingState = ({ + children, + overrides, + pathname, +}: { + children: React.ReactNode; + overrides: CrumbOverridesProps[]; + pathname: string; +}) => { + return ( + <> + + + {children} + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx index 0f9b5ae5cda..90e884ead4c 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.test.tsx @@ -34,22 +34,22 @@ const cancelEdit = 'cancel-save-resources'; // Mock Queries const queryMocks = vi.hoisted(() => ({ - useAlertDefinitionQuery: vi.fn(), useEditAlertDefinition: vi.fn(), useRegionsQuery: vi.fn(), useResourcesQuery: vi.fn(), })); + vi.mock('src/queries/cloudpulse/alerts', () => ({ ...vi.importActual('src/queries/cloudpulse/alerts'), - useAlertDefinitionQuery: queryMocks.useAlertDefinitionQuery, useEditAlertDefinition: queryMocks.useEditAlertDefinition, })); + vi.mock('src/queries/cloudpulse/resources', () => ({ ...vi.importActual('src/queries/cloudpulse/resources'), useResourcesQuery: queryMocks.useResourcesQuery, })); -vi.mock('src/queries/regions/regions', () => ({ - ...vi.importActual('src/queries/regions/regions'), +vi.mock('@linode/queries', async (importOriginal) => ({ + ...(await importOriginal()), useRegionsQuery: queryMocks.useRegionsQuery, })); @@ -61,11 +61,6 @@ beforeAll(() => { // Shared Setup beforeEach(() => { vi.clearAllMocks(); - queryMocks.useAlertDefinitionQuery.mockReturnValue({ - data: alertDetails, - isError: false, - isLoading: false, - }); queryMocks.useResourcesQuery.mockReturnValue({ data: cloudPulseResources, isError: false, @@ -83,48 +78,16 @@ beforeEach(() => { }); describe('EditAlertResources component tests', () => { - it('Edit alert resources happy path', async () => { - const { getByPlaceholderText, getByText } = renderWithTheme( - + it('Edit alert resources happy path', () => { + const { getByPlaceholderText, getByTestId } = renderWithTheme( + ); - // validate resources sections is rendered + expect( getByPlaceholderText('Search for a Region or Resource') ).toBeInTheDocument(); expect(getByPlaceholderText('Select Regions')).toBeInTheDocument(); - expect(getByText(alertDetails.label)).toBeInTheDocument(); - }); - - it('Edit alert resources alert details error and loading path', () => { - queryMocks.useAlertDefinitionQuery.mockReturnValue({ - data: undefined, - isError: true, // simulate error - isLoading: false, - }); - const { getByText } = renderWithTheme(); - expect( - getByText( - 'An error occurred while loading the alerts definitions and resources. Please try again later.' - ) - ).toBeInTheDocument(); - - queryMocks.useAlertDefinitionQuery.mockReturnValue({ - data: undefined, - isError: false, - isLoading: true, // simulate loading - }); - const { getByTestId } = renderWithTheme(); - expect(getByTestId('circle-progress')).toBeInTheDocument(); - }); - - it('Edit alert resources alert details empty path', () => { - queryMocks.useAlertDefinitionQuery.mockReturnValue({ - data: undefined, // simulate empty - isError: false, - isLoading: false, - }); - const { getByText } = renderWithTheme(); - expect(getByText('No Data to display.')).toBeInTheDocument(); + expect(getByTestId('show_selected_only')).toBeInTheDocument(); }); it('Edit alert resources successful edit', async () => { @@ -133,11 +96,11 @@ describe('EditAlertResources component tests', () => { const push = vi.fn(); const history = createMemoryHistory(); // Create a memory history for testing history.push = push; - history.push('/monitor/alerts/definitions/edit/linode/1'); + history.push('/alerts/definitions/edit/linode/1'); const { getByTestId, getByText } = renderWithTheme( - + ); @@ -157,7 +120,7 @@ describe('EditAlertResources component tests', () => { expect(mutateAsyncSpy).toHaveBeenCalledTimes(1); // check if edit is called - expect(push).toHaveBeenLastCalledWith('/monitor/alerts/definitions'); // after confirmation history updates to list page + expect(push).toHaveBeenLastCalledWith('/alerts/definitions'); // after confirmation history updates to list page await waitFor(() => { expect( @@ -170,7 +133,7 @@ describe('EditAlertResources component tests', () => { expect(push).toHaveBeenLastCalledWith( // after cancel click history updates to list page - '/monitor/alerts/definitions' + '/alerts/definitions' ); }); @@ -185,11 +148,11 @@ describe('EditAlertResources component tests', () => { const push = vi.fn(); const history = createMemoryHistory(); // Create a memory history for testing history.push = push; - history.push('/monitor/alerts/definitions/edit/linode/1'); + history.push('/alerts/definitions/edit/linode/1'); const { getByTestId, getByText } = renderWithTheme( - + ); diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx index 2cea747cec1..d4f1dff6640 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResources.tsx @@ -1,41 +1,29 @@ -import { Box, Button, CircleProgress } from '@linode/ui'; +import { Box, Button } from '@linode/ui'; import { useTheme } from '@mui/material'; import { enqueueSnackbar } from 'notistack'; import React from 'react'; -import { useHistory, useParams } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; -import EntityIcon from 'src/assets/icons/entityIcons/alerts.svg'; import { Breadcrumb } from 'src/components/Breadcrumb/Breadcrumb'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; -import { - useAlertDefinitionQuery, - useEditAlertDefinition, -} from 'src/queries/cloudpulse/alerts'; +import { useEditAlertDefinition } from 'src/queries/cloudpulse/alerts'; -import { StyledPlaceholder } from '../AlertsDetail/AlertDetail'; import { AlertResources } from '../AlertsResources/AlertsResources'; import { isResourcesEqual } from '../Utils/AlertResourceUtils'; import { getAlertBoxStyles } from '../Utils/utils'; import { EditAlertResourcesConfirmDialog } from './EditAlertResourcesConfirmationDialog'; -import type { AlertRouteParams } from '../AlertsDetail/AlertDetail'; -import type { CrumbOverridesProps } from 'src/components/Breadcrumb/Crumbs'; - -export const EditAlertResources = () => { - const { alertId, serviceType } = useParams(); +import type { EditAlertProps } from './EditAlertDefinition'; +export const EditAlertResources = (props: EditAlertProps) => { const theme = useTheme(); const history = useHistory(); - const definitionLanding = '/monitor/alerts/definitions'; - - const { data: alertDetails, isError, isLoading } = useAlertDefinitionQuery( - alertId, - serviceType - ); + const definitionLanding = '/alerts/definitions'; - const { mutateAsync: editAlert } = useEditAlertDefinition(); + const { alertDetails, serviceType } = props; + const alertId = alertDetails.id; + const { isPending, mutateAsync: editAlert } = useEditAlertDefinition(); const [selectedResources, setSelectedResources] = React.useState( [] ); @@ -67,18 +55,19 @@ export const EditAlertResources = () => { }, [serviceType, alertId]); const saveResources = () => { - setShowConfirmation(false); editAlert({ alertId, entity_ids: selectedResources, serviceType, }) .then(() => { + setShowConfirmation(false); // on success land on the alert definition list page and show a success snackbar history.push(definitionLanding); showSnackbar('Alert resources successfully updated.', 'success'); }) .catch(() => { + setShowConfirmation(false); showSnackbar( 'Error while updating the resources. Try again later.', 'error' @@ -90,30 +79,6 @@ export const EditAlertResources = () => { [alertDetails, selectedResources] ); - if (isLoading) { - return getEditAlertMessage(, newPathname, overrides); - } - - if (isError) { - return getEditAlertMessage( - , - newPathname, - overrides - ); - } - - if (!alertDetails) { - return getEditAlertMessage( - , - newPathname, - overrides - ); - } - const handleResourcesSelection = (resourceIds: string[]) => { setSelectedResources(resourceIds); // keep track of the selected resources and update it on save }; @@ -148,7 +113,7 @@ export const EditAlertResources = () => { setShowConfirmation((prev) => !prev)} onConfirm={saveResources} openConfirmationDialog={showConfirmation} @@ -181,37 +147,13 @@ export const EditAlertResources = () => { ); }; -/** - * Returns a common UI structure for loading, error, or empty states. - * @param messageComponent - A React component to display (e.g., CircleProgress, ErrorState, or Placeholder). - * @param pathName - The current pathname to be provided in breadcrumb - * @param crumbOverrides - The overrides to be provided in breadcrumb - */ -const getEditAlertMessage = ( - messageComponent: React.ReactNode, - pathName: string, - crumbOverrides: CrumbOverridesProps[] -) => { - return ( - <> - - - {messageComponent} - - - ); -}; - const showSnackbar = (message: string, variant: 'error' | 'success') => { enqueueSnackbar(message, { anchorOrigin: { horizontal: 'right', - vertical: 'top', // Show snackbar at the top + vertical: 'bottom', // Show snackbar at the bottom }, autoHideDuration: 2000, - style: { - marginTop: '150px', - }, variant, }); }; diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResourcesConfirmationDialog.tsx b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResourcesConfirmationDialog.tsx index afe4b2de2b5..e141676bca4 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResourcesConfirmationDialog.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResourcesConfirmationDialog.tsx @@ -1,12 +1,16 @@ -import { Typography } from '@linode/ui'; +import { ActionsPanel, Typography } from '@linode/ui'; import React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import type { ActionPanelProps } from 'src/components/ActionsPanel/ActionsPanel'; +import type { ActionPanelProps } from '@linode/ui'; interface AlertResourcesConfirmDialogProps { + /** + * Boolean flag to control the loading state of the confirm button based on api call pending for result state + */ + isApiResponsePending: boolean; + /** * Callback function to handle closing the confirmation dialog. */ @@ -25,15 +29,22 @@ interface AlertResourcesConfirmDialogProps { export const EditAlertResourcesConfirmDialog = React.memo( (props: AlertResourcesConfirmDialogProps) => { - const { onClose, onConfirm, openConfirmationDialog } = props; + const { + isApiResponsePending, + onClose, + onConfirm, + openConfirmationDialog, + } = props; const actionProps: ActionPanelProps = { primaryButtonProps: { 'data-testid': 'edit-confirmation', label: 'Confirm', + loading: isApiResponsePending, onClick: onConfirm, }, secondaryButtonProps: { + disabled: isApiResponsePending, label: 'Cancel', onClick: onClose, }, @@ -49,7 +60,7 @@ export const EditAlertResourcesConfirmDialog = React.memo( > ({ - font: theme.tokens.typography.Body, + font: theme.tokens.alias.Typography.Body, })} variant="body1" > diff --git a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/schemas.ts b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/schemas.ts new file mode 100644 index 00000000000..e1d48171d7d --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/schemas.ts @@ -0,0 +1,8 @@ +import { createAlertDefinitionSchema } from '@linode/validation'; +import { object, string } from 'yup'; + +export const EditAlertDefinitionFormSchema = createAlertDefinitionSchema.concat( + object({ + status: string().oneOf(['enabled', 'disabled']).optional(), + }) +); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts index 169f9daa166..4d286c25d4f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.test.ts @@ -4,6 +4,7 @@ import { getFilteredResources, getRegionOptions, getRegionsIdRegionMap, + getSupportedRegionIds, } from './AlertResourceUtils'; import type { CloudPulseResources } from '../../shared/CloudPulseResourcesSelect'; @@ -178,3 +179,32 @@ describe('getFilteredResources', () => { expect(result.length).toBe(data.length); }); }); + +describe('getSupportedRegionIds', () => { + const mockResourceTypeMap = [ + { + dimensionKey: 'LINODE_ID', + serviceType: 'linode', + supportedRegionIds: 'us-east,us-west,us-central,us-southeast', + }, + ]; + + it('should return supported region ids', () => { + const result = getSupportedRegionIds( + mockResourceTypeMap, + 'linode' + ) as string[]; + expect(result.length).toBe(4); + }); + it('should return undefined if no supported region ids are defined in resource type map for the given service type', () => { + const mockResourceTypeMap = [ + { + dimensionKey: 'LINODE_ID', + serviceType: 'linode', + }, + ]; + + const result = getSupportedRegionIds(mockResourceTypeMap, 'linode'); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts index 1f4eaf5550b..61e6ae6cef9 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertResourceUtils.ts @@ -4,15 +4,15 @@ import { } from '../AlertsResources/constants'; import type { CloudPulseResources } from '../../shared/CloudPulseResourcesSelect'; -import type { AlertsEngineOptionProps } from '../AlertsResources/AlertsEngineTypeFilter'; -import type { AlertsRegionProps } from '../AlertsResources/AlertsRegionFilter'; import type { AlertInstance } from '../AlertsResources/DisplayAlertResources'; import type { AlertAdditionalFilterKey, AlertFilterKey, AlertFilterType, + AlertResourceFiltersProps, } from '../AlertsResources/types'; -import type { Region } from '@linode/api-v4'; +import type { AlertServiceType, Region } from '@linode/api-v4'; +import type { CloudPulseResourceTypeMapFlag } from 'src/featureFlags'; interface FilterResourceProps { /** @@ -83,6 +83,11 @@ interface FilterRendererProps { * The regions to be displayed according to the resources associated with alerts */ regionOptions: Region[]; + + /** + * The tags to be displayed according to the resources associated with alerts + */ + tagOptions: string[]; } /** @@ -132,6 +137,31 @@ export const getRegionOptions = ( return Array.from(uniqueRegions); }; +/** + * @param aclpResourceTypeMap The launch darkly flag where supported region ids are listed + * @param serviceType The service type associated with the alerts + * @returns Array of supported regions associated with the resource ids of the alert + */ +export const getSupportedRegionIds = ( + aclpResourceTypeMap: CloudPulseResourceTypeMapFlag[] | undefined, + serviceType: AlertServiceType | undefined +): string[] | undefined => { + const resourceTypeFlag = aclpResourceTypeMap?.find( + (item: CloudPulseResourceTypeMapFlag) => item.serviceType === serviceType + ); + + if ( + resourceTypeFlag?.supportedRegionIds === null || + resourceTypeFlag?.supportedRegionIds === undefined + ) { + return undefined; + } + + return resourceTypeFlag.supportedRegionIds + .split(',') + .map((regionId: string) => regionId.trim()); +}; + /** * @param filterProps Props required to filter the resources on the table * @returns Filtered instances to be displayed on the table @@ -184,9 +214,10 @@ export const getFilteredResources = ( (region.length && filteredRegions.includes(region)); // check with filtered region return ( + // if selected only, show only checked, else everything matchesSearchText && matchesFilteredRegions && - (!selectedOnly || checked) // if selected only, show only checked, else everything + (!selectedOnly || checked) ); // match the search text and match the region selected }) .filter((resource) => applyAdditionalFilter(resource, additionalFilters)); @@ -228,7 +259,9 @@ const applyAdditionalFilter = ( * This methods scrolls to the given HTML Element * @param scrollToElement The HTML Element to which we need to scroll */ -export const scrollToElement = (scrollToElement: HTMLDivElement | null) => { +export const scrollToElement = ( + scrollToElement: HTMLButtonElement | HTMLDivElement | null +) => { if (scrollToElement) { window.scrollTo({ behavior: 'smooth', @@ -296,13 +329,15 @@ export const getAlertResourceFilterProps = ({ handleFilterChange, handleFilteredRegionsChange: handleSelectionChange, regionOptions, -}: FilterRendererProps): AlertsEngineOptionProps | AlertsRegionProps => { + tagOptions, +}: FilterRendererProps): AlertResourceFiltersProps => { switch (filterKey) { case 'engineType': return { handleFilterChange }; case 'region': return { handleSelectionChange, regionOptions }; - + case 'tags': + return { handleFilterChange, tagOptions }; default: return { handleFilterChange }; } diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts index 4b4841df6b5..c95bef592df 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts @@ -29,6 +29,12 @@ export const getAlertTypeToActionsList = ( title: 'Show Details', }, { + disabled: alertStatus === 'in progress' || alertStatus === 'failed', + onClick: handleEdit, + title: 'Edit', + }, + { + disabled: alertStatus === 'in progress' || alertStatus === 'failed', onClick: handleEnableDisable, title: getTitleForEnableDisable(alertStatus), }, diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsNoticeMessage.tsx b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsNoticeMessage.tsx new file mode 100644 index 00000000000..6f1c697482c --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsNoticeMessage.tsx @@ -0,0 +1,37 @@ +import { Typography } from '@linode/ui'; +import { Grid } from '@mui/material'; +import React from 'react'; + +import { StyledNotice } from '../AlertsResources/AlertsResourcesNotice'; + +import type { NoticeVariant } from '@linode/ui'; + +interface AlertsNoticeProps { + /** + * The text that needs to be displayed in the notice + */ + text: string; + /** + * The variant of notice like info, error, warning + */ + variant?: NoticeVariant; +} + +export const AlertsNoticeMessage = (props: AlertsNoticeProps) => { + const { text, variant } = props; + + return ( + + + ({ + fontFamily: theme.tokens.alias.Typography.Body.Bold, + })} + data-testid="alert_message_notice" + > + {text} + + + + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts index 7fcdd3f5873..e216d30b38f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.test.ts @@ -1,6 +1,21 @@ -import { serviceTypesFactory } from 'src/factories'; +import { object } from 'yup'; -import { convertSecondsToMinutes, getServiceTypeLabel } from './utils'; +import { alertFactory, serviceTypesFactory } from 'src/factories'; + +import { + convertAlertDefinitionValues, + convertAlertsToTypeSet, + convertSecondsToMinutes, + enhanceValidationSchemaWithEntityIdValidation, + filterAlertsByStatusAndType, + getServiceTypeLabel, +} from './utils'; + +import type { CreateAlertDefinitionForm } from '../CreateAlert/types'; +import type { AlertValidationSchemaProps } from './utils'; +import type { Alert, EditAlertPayloadWithService } from '@linode/api-v4'; +import type { AclpAlertServiceTypeConfig } from 'src/featureFlags'; +import type { ObjectSchema } from 'yup'; it('test getServiceTypeLabel method', () => { const services = serviceTypesFactory.buildList(3); @@ -21,3 +36,99 @@ it('test convertSecondsToMinutes method', () => { expect(convertSecondsToMinutes(1)).toBe('1 second'); expect(convertSecondsToMinutes(59)).toBe('59 seconds'); }); + +it('test filterAlertsByStatusAndType method', () => { + const alerts = alertFactory.buildList(12, { created_by: 'system' }); + expect(filterAlertsByStatusAndType(alerts, '', 'system')).toHaveLength(12); + expect(filterAlertsByStatusAndType(alerts, '', 'user')).toHaveLength(0); + expect(filterAlertsByStatusAndType(alerts, 'Alert-1', 'system')).toHaveLength( + 4 + ); +}); +it('test convertAlertsToTypeSet method', () => { + const alerts = alertFactory.buildList(12, { created_by: 'user' }); + + expect(convertAlertsToTypeSet(alerts)).toHaveLength(1); +}); + +it('should correctly convert an alert definition values to the required format', () => { + const alert: Alert = alertFactory.build(); + const serviceType = 'linode'; + const { + alert_channels, + description, + entity_ids, + id, + label, + rule_criteria, + severity, + tags, + trigger_conditions, + } = alert; + const expected: EditAlertPayloadWithService = { + alertId: id, + channel_ids: alert_channels.map((channel) => channel.id), + description: description || undefined, + entity_ids, + label, + rule_criteria: { + rules: rule_criteria.rules.map((rule) => ({ + ...rule, + dimension_filters: + rule.dimension_filters?.map(({ label, ...filter }) => filter) ?? [], + })), + }, + serviceType, + severity, + tags, + trigger_conditions, + }; + + expect(convertAlertDefinitionValues(alert, serviceType)).toEqual(expected); +}); + +describe('getValidationSchema', () => { + const baseSchema = object({}) as ObjectSchema; + const aclpAlertServiceTypeConfig: AclpAlertServiceTypeConfig[] = [ + { maxResourceSelectionCount: 3, serviceType: 'dbaas' }, + { maxResourceSelectionCount: 5, serviceType: 'linode' }, + ]; + const props: AlertValidationSchemaProps = { + aclpAlertServiceTypeConfig, + baseSchema, + serviceTypeObj: 'dbaas', + }; + + it('should return baseSchema if maxSelectionCount is undefined', () => { + const schema = enhanceValidationSchemaWithEntityIdValidation({ + ...props, + serviceTypeObj: 'unknown', + }); + expect(schema).toBe(baseSchema); + }); + + it("should return schema with maxSelectionCount for 'dbaas'", async () => { + const schema = enhanceValidationSchemaWithEntityIdValidation({ ...props }); + + await expect( + schema.validate({ entity_ids: ['id1', 'id2', 'id3', 'id4'] }) + ).rejects.toThrow( + "The overall number of resources assigned to an alert can't exceed 3." + ); + }); + + it("should return schema with correct maxSelectionCount for 'linode'", async () => { + const schema = enhanceValidationSchemaWithEntityIdValidation({ + ...props, + serviceTypeObj: 'linode', + }); + + await expect( + schema.validate({ + entity_ids: ['id1', 'id2', 'id3', 'id4', 'id5', 'id6'], + }) + ).rejects.toThrow( + "The overall number of resources assigned to an alert can't exceed 5." + ); + }); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts index e139d601a05..4b20eb8b05e 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts @@ -1,6 +1,22 @@ +import { array, object, string } from 'yup'; + +import { aggregationTypeMap, metricOperatorTypeMap } from '../constants'; + import type { AlertDimensionsProp } from '../AlertsDetail/DisplayAlertDetailChips'; -import type { NotificationChannel, ServiceTypesList } from '@linode/api-v4'; +import type { CreateAlertDefinitionForm } from '../CreateAlert/types'; +import type { + Alert, + AlertDefinitionMetricCriteria, + AlertDefinitionType, + AlertServiceType, + EditAlertDefinitionPayload, + EditAlertPayloadWithService, + NotificationChannel, + ServiceTypesList, +} from '@linode/api-v4'; import type { Theme } from '@mui/material'; +import type { AclpAlertServiceTypeConfig } from 'src/featureFlags'; +import type { ObjectSchema } from 'yup'; interface AlertChipBorderProps { /** @@ -22,6 +38,47 @@ interface AlertChipBorderProps { mergeChips: boolean | undefined; } +export interface ProcessedCriteria { + /** + * Label for the metric criteria + */ + label: string; + /** + * Aggregation type for the metric criteria + */ + metricAggregationType: string; + /** + * Comparison operator for the metric criteria + */ + metricOperator: string; + /** + * Threshold value for the metric criteria + */ + threshold: number; + /** + * Unit for the threshold value + */ + unit: string; +} + +export interface AlertValidationSchemaProps { + /** + * The config that holds the maxResourceSelection count per service type like linode, dbaas etc., + */ + aclpAlertServiceTypeConfig: AclpAlertServiceTypeConfig[]; + /** + * The base schema which needs to be enhanced with the entity_ids validation + */ + baseSchema: ObjectSchema< + CreateAlertDefinitionForm | EditAlertDefinitionPayload + >; + + /** + * The service type that is linked with alert and for which the validation schema needs to be built + */ + serviceTypeObj: null | string; +} + /** * @param serviceType Service type for which the label needs to be displayed * @param serviceTypeList List of available service types in Cloud Pulse @@ -47,7 +104,7 @@ export const getServiceTypeLabel = ( * @returns The style object for the box used in alert details page */ export const getAlertBoxStyles = (theme: Theme) => ({ - backgroundColor: theme.tokens.background.Neutral, + backgroundColor: theme.tokens.alias.Background.Neutral, padding: theme.spacing(3), }); /** @@ -118,3 +175,136 @@ export const getChipLabels = ( }; } }; + +/** + * + * @param alerts list of alerts to be filtered + * @param searchText text to be searched in alert name + * @param selectedType selecte alert type + * @returns list of filtered alerts based on searchText & selectedType + */ +export const filterAlertsByStatusAndType = ( + alerts: Alert[] | undefined, + searchText: string, + selectedType: string | undefined +): Alert[] => { + return ( + alerts?.filter(({ label, status, type }) => { + return ( + status === 'enabled' && + (!selectedType || type === selectedType) && + (!searchText || label.toLowerCase().includes(searchText.toLowerCase())) + ); + }) ?? [] + ); +}; + +/** + * + * @param alerts list of alerts + * @returns list of unique alert types in the alerts list in the form of json object + */ +export const convertAlertsToTypeSet = ( + alerts: Alert[] | undefined +): { label: AlertDefinitionType }[] => { + const types = new Set(alerts?.map(({ type }) => type) ?? []); + + return Array.from(types).reduce( + (previousValue, type) => [...previousValue, { label: type }], + [] + ); +}; + +/** + * Filters and maps the alert data to match the form structure. + * @param alert The alert object to be mapped. + * @param serviceType The service type for the alert. + * @returns The formatted alert values suitable for the form. + */ +export const convertAlertDefinitionValues = ( + { + alert_channels, + description, + entity_ids, + id, + label, + rule_criteria, + severity, + tags, + trigger_conditions, + }: Alert, + serviceType: AlertServiceType +): EditAlertPayloadWithService => { + return { + alertId: id, + channel_ids: alert_channels.map((channel) => channel.id), + description: description || undefined, + entity_ids, + label, + rule_criteria: { + rules: rule_criteria.rules.map((rule) => ({ + ...rule, + dimension_filters: + rule.dimension_filters?.map(({ label, ...filter }) => filter) ?? [], + })), + }, + serviceType, + severity, + tags, + trigger_conditions, + }; +}; + +/** + * + * @param criterias list of metric criterias to be processed + * @returns list of metric criterias in processed form + */ +export const processMetricCriteria = ( + criterias: AlertDefinitionMetricCriteria[] +): ProcessedCriteria[] => { + return criterias.map( + ({ aggregate_function, label, operator, threshold, unit }) => { + return { + label, + metricAggregationType: aggregationTypeMap[aggregate_function], + metricOperator: metricOperatorTypeMap[operator], + threshold, + unit, + }; + } + ); +}; + +/** + * @param props The props required to enhance the validation schema + * @returns The validation schema updated with max selection count for entity_ids based on service type + */ +export const enhanceValidationSchemaWithEntityIdValidation = ( + props: AlertValidationSchemaProps +): ObjectSchema => { + const { aclpAlertServiceTypeConfig, baseSchema, serviceTypeObj } = props; + + if (!serviceTypeObj || !aclpAlertServiceTypeConfig.length) { + return baseSchema; + } + + const maxSelectionCount = aclpAlertServiceTypeConfig.find( + ({ serviceType }) => serviceTypeObj === serviceType + )?.maxResourceSelectionCount; + + return maxSelectionCount === undefined + ? baseSchema + : baseSchema.concat( + object({ + entity_ids: array() + .of(string()) + .max( + maxSelectionCount, + `The overall number of resources assigned to an alert can't exceed ${maxSelectionCount}.` + ), + }) as ObjectSchema< + CreateAlertDefinitionForm | EditAlertDefinitionPayload + > + ); +}; diff --git a/packages/manager/src/features/CloudPulse/Alerts/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/constants.ts index 8b91797b6bd..0bddacdb14f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/constants.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/constants.ts @@ -48,7 +48,7 @@ export const metricOperatorOptions: Item[] = [ value: 'lte', }, { - label: '==', + label: '=', value: 'eq', }, ]; @@ -128,6 +128,8 @@ export const severityMap: Record = { export const alertStatusToIconStatusMap: Record = { disabled: 'inactive', enabled: 'active', + failed: 'error', + 'in progress': 'other', }; export const channelTypeOptions: Item[] = [ @@ -163,6 +165,8 @@ export const dimensionOperatorTypeMap: Record< export const alertStatuses: Record = { disabled: 'Disabled', enabled: 'Enabled', + failed: 'Failed', + 'in progress': 'In Progress', }; export const alertStatusOptions: Item< diff --git a/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx b/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx deleted file mode 100644 index d303457db05..00000000000 --- a/packages/manager/src/features/CloudPulse/CloudPulseLanding.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { createLazyRoute } from '@tanstack/react-router'; -import * as React from 'react'; -import { Route, Switch } from 'react-router-dom'; - -import { LandingHeader } from 'src/components/LandingHeader/LandingHeader'; -import { SuspenseLoader } from 'src/components/SuspenseLoader'; - -import { CloudPulseTabs } from './CloudPulseTabs'; -export const CloudPulseLanding = () => { - return ( - <> - - }> - - - - - - ); -}; - -export const cloudPulseLandingLazyRoute = createLazyRoute('/monitor')({ - component: CloudPulseLanding, -}); diff --git a/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx b/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx deleted file mode 100644 index 6d0a40ad654..00000000000 --- a/packages/manager/src/features/CloudPulse/CloudPulseTabs.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import * as React from 'react'; -import { - Redirect, - Route, - Switch, - useHistory, - useLocation, - useRouteMatch, -} from 'react-router-dom'; - -import { SuspenseLoader } from 'src/components/SuspenseLoader'; -import { TabLinkList } from 'src/components/Tabs/TabLinkList'; -import { Tabs } from 'src/components/Tabs/Tabs'; -import { useFlags } from 'src/hooks/useFlags'; - -import { AlertsLanding } from './Alerts/AlertsLanding/AlertsLanding'; -import { CloudPulseDashboardLanding } from './Dashboard/CloudPulseDashboardLanding'; - -import type { Tab } from 'src/components/Tabs/TabLinkList'; - -export type EnabledAlertTab = { - isEnabled: boolean; - tab: Tab; -}; -export const CloudPulseTabs = () => { - const flags = useFlags(); - const { url } = useRouteMatch(); - const { pathname } = useLocation(); - const history = useHistory(); - const alertTabs = React.useMemo( - () => [ - { - isEnabled: true, - tab: { - routeName: `${url}/dashboards`, - title: 'Dashboards', - }, - }, - { - isEnabled: Boolean( - flags.aclpAlerting?.alertDefinitions || - flags.aclpAlerting?.recentActivity || - flags.aclpAlerting?.notificationChannels - ), - tab: { - routeName: `${url}/alerts`, - title: 'Alerts', - }, - }, - ], - [url, flags.aclpAlerting] - ); - const accessibleTabs = React.useMemo( - () => - alertTabs - .filter((alertTab) => alertTab.isEnabled) - .map((alertTab) => alertTab.tab), - [alertTabs] - ); - const activeTabIndex = React.useMemo( - () => - Math.max( - accessibleTabs.findIndex((tab) => pathname.startsWith(tab.routeName)), - 0 - ), - [accessibleTabs, pathname] - ); - const handleChange = (index: number) => { - history.push(alertTabs[index].tab.routeName); - }; - return ( - - - - }> - - - - - - - - ); -}; diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx index 9a77d27fb44..b5bae381f73 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboard.tsx @@ -1,8 +1,7 @@ -import { CircleProgress } from '@linode/ui'; +import { CircleProgress, ErrorState } from '@linode/ui'; import { Grid } from '@mui/material'; import React from 'react'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboards'; import { useResourcesQuery } from 'src/queries/cloudpulse/resources'; import { diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx index 78661b048a8..0fb683e1f13 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.test.tsx @@ -50,6 +50,8 @@ describe('CloudPulseDashboardFilterBuilder component tests', () => { it('should render error placeholder if dashboard not selected', () => { const screen = renderWithTheme(); + expect(screen.getByText('metrics')).toBeInTheDocument(); + expect(screen.getByPlaceholderText(selectDashboardLabel)).toHaveAttribute( 'value', '' diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx index a2d4574955e..3517fce46f7 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx @@ -1,6 +1,12 @@ import { Box, Paper } from '@linode/ui'; import { Grid } from '@mui/material'; +import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; +import { Redirect } from 'react-router-dom'; + +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { LandingHeader } from 'src/components/LandingHeader'; +import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { GlobalFilters } from '../Overview/GlobalFilters'; import { CloudPulseAppliedFilterRenderer } from '../shared/CloudPulseAppliedFilterRenderer'; @@ -75,30 +81,43 @@ export const CloudPulseDashboardLanding = () => { [] ); return ( - - - - - - {dashboard?.service_type && showAppliedFilters && ( - }> + + + + + + + - )} - - + {dashboard?.service_type && showAppliedFilters && ( + + )} + + + + - -
+ + ); }; + +export const cloudPulseMetricsLandingLazyRoute = createLazyRoute('/metrics')({ + component: CloudPulseDashboardLanding, +}); diff --git a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx index 9db1f7384f2..cb6910fc598 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardWithFilters.tsx @@ -1,8 +1,7 @@ -import { Box, CircleProgress, Divider, Paper } from '@linode/ui'; +import { Box, CircleProgress, Divider, ErrorState, Paper } from '@linode/ui'; import { Grid } from '@mui/material'; import React from 'react'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { useCloudPulseDashboardByIdQuery } from 'src/queries/cloudpulse/dashboards'; import { CloudPulseAppliedFilterRenderer } from '../shared/CloudPulseAppliedFilterRenderer'; @@ -172,7 +171,14 @@ export const CloudPulseDashboardWithFilters = React.memo( resource_ids={[resource]} /> )} - + {showAppliedFilters && ( { handleAnyFilterChange(REFRESH, Date.now(), []); }, []); - const theme = useTheme(); - return ( @@ -122,10 +120,10 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { /> ({ marginBlockEnd: 'auto', marginTop: { md: theme.spacing(3.5) }, - }} + })} aria-label="Refresh Dashboard Metrics" data-testid="global-refresh" disabled={!selectedDashboard} @@ -141,10 +139,10 @@ export const GlobalFilters = React.memo((props: GlobalFilterProperties) => { {selectedDashboard && ( ({ borderColor: theme.color.grey5, margin: 0, - }} + })} /> )} diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts index c9a40edf80f..e62687fdfbb 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.test.ts @@ -1,4 +1,4 @@ -import { formatPercentage } from 'src/utilities/statMetrics'; +import { formatPercentage } from '@linode/utilities'; import { generateGraphData, diff --git a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts index a53c42e4c9a..979049493c9 100644 --- a/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/CloudPulseWidgetUtils.ts @@ -1,6 +1,5 @@ import { Alias } from '@linode/design-language-system'; - -import { getMetrics } from 'src/utilities/statMetrics'; +import { getMetrics } from '@linode/utilities'; import { convertValueToUnit, diff --git a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts index 790d1083050..ed0c12380da 100644 --- a/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/ReusableDashboardFilterUtils.ts @@ -110,9 +110,10 @@ export const checkIfFilterNeededInMetricsCall = ( } = configuration; return ( + // Indicates if this filter should be included in the metrics call configFilterKey === filterKey && Boolean(isFilterable) && - neededInServicePage // Indicates if this filter should be included in the metrics call + neededInServicePage ); }); }; diff --git a/packages/manager/src/features/CloudPulse/Utils/UserPreference.ts b/packages/manager/src/features/CloudPulse/Utils/UserPreference.ts index 69d193c87e8..dd22344a339 100644 --- a/packages/manager/src/features/CloudPulse/Utils/UserPreference.ts +++ b/packages/manager/src/features/CloudPulse/Utils/UserPreference.ts @@ -1,9 +1,6 @@ import { useRef } from 'react'; -import { - useMutatePreferences, - usePreferences, -} from 'src/queries/profile/preferences'; +import { useMutatePreferences, usePreferences } from '@linode/queries'; import { DASHBOARD_ID, WIDGETS } from './constants'; diff --git a/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts b/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts index a22a473c7e7..06d4687027d 100644 --- a/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts +++ b/packages/manager/src/features/CloudPulse/Utils/unitConversion.ts @@ -1,4 +1,4 @@ -import { roundTo } from 'src/utilities/roundTo'; +import { roundTo } from '@linode/utilities'; const supportedUnits = { B: 'Bytes', diff --git a/packages/manager/src/features/CloudPulse/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Utils/utils.ts index 6801fd4b034..4fab125d6fa 100644 --- a/packages/manager/src/features/CloudPulse/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Utils/utils.ts @@ -1,7 +1,8 @@ +import { useAccount } from '@linode/queries'; +import { isFeatureEnabledV2 } from '@linode/utilities'; + import { convertData } from 'src/features/Longview/shared/formatters'; import { useFlags } from 'src/hooks/useFlags'; -import { useAccount } from 'src/queries/account/account'; -import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import type { APIError, diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx index b63988878d4..673ba3ef1b3 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.test.tsx @@ -1,3 +1,4 @@ +import { formatPercentage } from '@linode/utilities'; import userEvent from '@testing-library/user-event'; import { DateTime } from 'luxon'; import React from 'react'; @@ -7,7 +8,6 @@ import { widgetFactory, } from 'src/factories'; import * as CloudPulseWidgetUtils from 'src/features/CloudPulse/Utils/CloudPulseWidgetUtils'; -import { formatPercentage } from 'src/utilities/statMetrics'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { CloudPulseWidget } from './CloudPulseWidget'; diff --git a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index 482f633ecd3..664478d1dc6 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -1,3 +1,4 @@ +import { useProfile } from '@linode/queries'; import { Paper, Typography } from '@linode/ui'; import { Box, Grid, Stack, useTheme } from '@mui/material'; import { DateTime } from 'luxon'; @@ -5,7 +6,6 @@ import React from 'react'; import { useFlags } from 'src/hooks/useFlags'; import { useCloudPulseMetricsQuery } from 'src/queries/cloudpulse/metrics'; -import { useProfile } from 'src/queries/profile/profile'; import { generateGraphData, @@ -23,15 +23,19 @@ import { ZoomIcon } from './components/Zoomer'; import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; import type { CloudPulseResources } from '../shared/CloudPulseResourcesSelect'; -import type { DateTimeWithPreset, Widgets } from '@linode/api-v4'; -import type { MetricDefinition, TimeGranularity } from '@linode/api-v4'; -import type { DataSet } from 'src/components/AreaChart/AreaChart'; +import type { + DateTimeWithPreset, + MetricDefinition, + TimeGranularity, + Widgets, +} from '@linode/api-v4'; +import type { Metrics } from '@linode/utilities'; import type { AreaProps, ChartVariant, + DataSet, } from 'src/components/AreaChart/AreaChart'; import type { MetricsDisplayRow } from 'src/components/LineGraph/MetricsDisplay'; -import type { Metrics } from 'src/utilities/statMetrics'; export interface CloudPulseWidgetProperties { /** @@ -271,18 +275,25 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { const tickFormat = hours <= 24 ? 'hh:mm a' : 'LLL dd'; return ( - + {convertStringToCamelCasesWithSpaces(widget.label)} ( @@ -290,12 +301,14 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { {unit.endsWith('ps') ? '/s' : ''}) {availableMetrics?.scrape_interval && ( { const noDataMessage = 'No data to display'; return ( - + {error ? ( diff --git a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx index 85666e556c2..a04204db3eb 100644 --- a/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/components/Zoomer.tsx @@ -1,4 +1,4 @@ -import { IconButton, useTheme } from '@mui/material'; +import { IconButton } from '@mui/material'; import * as React from 'react'; import ZoomInMap from 'src/assets/icons/zoomin.svg'; @@ -13,8 +13,6 @@ export interface ZoomIconProperties { } export const ZoomIcon = React.memo((props: ZoomIconProperties) => { - const theme = useTheme(); - const handleClick = (needZoomIn: boolean) => { props.handleZoomToggle(needZoomIn); }; @@ -25,7 +23,6 @@ export const ZoomIcon = React.memo((props: ZoomIconProperties) => { { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseDateTimeRangePicker.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseDateTimeRangePicker.tsx index c4209d1711e..25bdf42b171 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseDateTimeRangePicker.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseDateTimeRangePicker.tsx @@ -2,7 +2,7 @@ import { DateTime } from 'luxon'; import React from 'react'; import { DateTimeRangePicker } from 'src/components/DatePicker/DateTimeRangePicker'; -import { useProfile } from 'src/queries/profile/profile'; +import { useProfile } from '@linode/queries'; import type { DateTimeWithPreset, FilterValue } from '@linode/api-v4'; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseErrorPlaceholder.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseErrorPlaceholder.tsx index fefa287ee1c..ee48340adce 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseErrorPlaceholder.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseErrorPlaceholder.tsx @@ -2,7 +2,7 @@ import { Grid, Paper } from '@mui/material'; import React from 'react'; import CloudPulseIcon from 'src/assets/icons/entityIcons/monitor.svg'; -import { StyledPlaceholder } from 'src/features/StackScripts/StackScriptBase/StackScriptBase.styles'; +import { Placeholder } from 'src/components/Placeholder/Placeholder'; export const CloudPulseErrorPlaceholder = React.memo( (props: { errorMessage: string }) => { @@ -10,7 +10,7 @@ export const CloudPulseErrorPlaceholder = React.memo( return ( - = { ] as CloudPulseResourceTypeMapFlag[], }; -vi.mock('src/queries/regions/regions', async () => { - const actual = await vi.importActual('src/queries/regions/regions'); - return { - ...actual, - useRegionsQuery: queryMocks.useRegionsQuery, - }; -}); +vi.mock('@linode/queries', async (importOriginal) => ({ + ...(await importOriginal()), + useRegionsQuery: queryMocks.useRegionsQuery, +})); describe('CloudPulseRegionSelect', () => { it('should render a Region Select component', () => { @@ -58,7 +55,7 @@ describe('CloudPulseRegionSelect', () => { data: undefined, isError: true, isLoading: false, - } as ReturnType); + } as ReturnType); const { getByText } = renderWithTheme( ); diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx index f69ab97e964..41171287355 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseRegionSelect.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; import { useFlags } from 'src/hooks/useFlags'; -import { useRegionsQuery } from 'src/queries/regions/regions'; +import { useRegionsQuery } from '@linode/queries'; import { FILTER_CONFIG } from '../Utils/FilterConfig'; diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx index 9aebcec1d62..89ab8e0bf1c 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseResourcesSelect.tsx @@ -14,6 +14,7 @@ export interface CloudPulseResources { id: string; label: string; region?: string; + tags?: string[]; } export interface CloudPulseResourcesSelectProps { diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseTagsFilter.test.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTagsFilter.test.tsx index 34c77706bf2..d002827b82b 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseTagsFilter.test.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTagsFilter.test.tsx @@ -7,7 +7,7 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { CloudPulseTagsSelect } from './CloudPulseTagsFilter'; import type { CloudPulseTagsSelectProps } from './CloudPulseTagsFilter'; -import type { useAllLinodesQuery } from 'src/queries/linodes/linodes'; +import type { useAllLinodesQuery } from '@linode/queries'; const props: CloudPulseTagsSelectProps = { disabled: false, @@ -22,8 +22,8 @@ const queryMocks = vi.hoisted(() => ({ useAllLinodesQuery: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/linodes/linodes', async () => { - const actual = await vi.importActual('src/queries/linodes/linodes'); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); return { ...actual, useAllLinodesQuery: queryMocks.useAllLinodesQuery, diff --git a/packages/manager/src/features/CloudPulse/shared/CloudPulseTagsFilter.tsx b/packages/manager/src/features/CloudPulse/shared/CloudPulseTagsFilter.tsx index 17dc6359939..8d4e46d55d1 100644 --- a/packages/manager/src/features/CloudPulse/shared/CloudPulseTagsFilter.tsx +++ b/packages/manager/src/features/CloudPulse/shared/CloudPulseTagsFilter.tsx @@ -1,7 +1,7 @@ import { Autocomplete } from '@linode/ui'; import React from 'react'; -import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; +import { useAllLinodesQuery } from '@linode/queries'; import { themes } from 'src/utilities/theme'; import type { FilterValueType } from '../Dashboard/CloudPulseDashboardLanding'; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx index 4fa6bd6013a..000292b902e 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseClusterData.tsx @@ -1,5 +1,5 @@ import { Divider, Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import React from 'react'; import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx index 1b4c8241343..56b3df8cec8 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.test.tsx @@ -1,7 +1,5 @@ import { fireEvent, waitForElementToBeRemoved } from '@testing-library/react'; -import { createMemoryHistory } from 'history'; import * as React from 'react'; -import { Router } from 'react-router-dom'; import { accountFactory, databaseTypeFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; @@ -16,8 +14,8 @@ 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'); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); return { ...actual, useProfile: queryMocks.useProfile, @@ -68,15 +66,10 @@ describe('Database Create', () => { }) ); - // 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( - - - - ); + const { getAllByText, getByTestId } = renderWithTheme(, { + // Mock route history so the Plan Selection table displays prices without requiring a region in the DB Create flow. + MemoryRouter: { initialEntries: ['/databases/create'] }, + }); await waitForElementToBeRemoved(getByTestId(loadingTestId)); @@ -101,15 +94,10 @@ describe('Database Create', () => { }) ); - // 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( - - - - ); + const { getAllByText, getByTestId } = renderWithTheme(, { + // Mock route history so the Plan Selection table displays prices without requiring a region in the DB Create flow. + MemoryRouter: { initialEntries: ['/databases/create'] }, + }); await waitForElementToBeRemoved(getByTestId(loadingTestId)); @@ -135,10 +123,6 @@ describe('Database Create', () => { }) ); - // 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, @@ -146,12 +130,11 @@ describe('Database Create', () => { }, }; - const { getAllByText, getByTestId } = renderWithTheme( - - - , - { flags } - ); + const { getAllByText, getByTestId } = renderWithTheme(, { + // Mock route history so the Plan Selection table displays prices without requiring a region in the DB Create flow. + MemoryRouter: { initialEntries: ['/databases/create'] }, + flags, + }); await waitForElementToBeRemoved(getByTestId(loadingTestId)); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx index 49f17ea4eae..17c953b0301 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -1,13 +1,22 @@ -import { BetaChip, CircleProgress, Divider, Notice, Paper } from '@linode/ui'; +import { useRegionsQuery } from '@linode/queries'; +import { + BetaChip, + CircleProgress, + Divider, + ErrorState, + Notice, + Paper, +} from '@linode/ui'; +import { formatStorageUnits, scrollErrorIntoViewV2 } from '@linode/utilities'; import { createDatabaseSchema } from '@linode/validation/lib/databases.schema'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { createLazyRoute } from '@tanstack/react-router'; import { useFormik } from 'formik'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ErrorMessage } from 'src/components/ErrorMessage'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; import { getRestrictedResourceText } from 'src/features/Account/utils'; import { DatabaseClusterData } from 'src/features/Databases/DatabaseCreate/DatabaseClusterData'; @@ -29,12 +38,13 @@ import { useDatabaseEnginesQuery, useDatabaseTypesQuery, } from 'src/queries/databases/databases'; -import { useRegionsQuery } from 'src/queries/regions/regions'; -import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; import { handleAPIErrors } from 'src/utilities/formikErrorUtils'; import { validateIPs } from 'src/utilities/ipUtils'; -import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; +import { + ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT, + ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT_LEGACY, +} from '../constants'; import { DatabaseCreateAccessControls } from './DatabaseCreateAccessControls'; import { determineReplicationCommitType, @@ -51,11 +61,6 @@ import type { APIError } from '@linode/api-v4/lib/types'; import type { PlanSelectionWithDatabaseType } from 'src/features/components/PlansPanel/types'; import type { DatabaseCreateValues } from 'src/features/Databases/DatabaseCreate/DatabaseClusterData'; import type { ExtendedIP } from 'src/utilities/ipUtils'; -import { - ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT, - ACCESS_CONTROLS_IP_VALIDATION_ERROR_TEXT_LEGACY, -} from '../constants'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; const DatabaseCreate = () => { const history = useHistory(); diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.tsx index 63cbf139023..cd0883c22d5 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreateAccessControls.tsx @@ -5,7 +5,7 @@ import { RadioGroup, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { useState } from 'react'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx index 38d36566d97..49e981702f6 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseEngineSelect.tsx @@ -1,5 +1,5 @@ import { Autocomplete, Box } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import React from 'react'; import { getEngineOptions } from 'src/features/Databases/DatabaseCreate/utilities'; @@ -56,11 +56,13 @@ export const DatabaseEngineSelect = (props: Props) => { return (
  • {option.flag} {option.label} diff --git a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseSummarySection.test.tsx b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseSummarySection.test.tsx index 57581973cdf..f7d7e5af5c7 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseSummarySection.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseSummarySection.test.tsx @@ -1,8 +1,6 @@ import { waitFor, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { createMemoryHistory } from 'history'; import * as React from 'react'; -import { Router } from 'react-router-dom'; import { databaseFactory, databaseTypeFactory } from 'src/factories'; import DatabaseCreate from 'src/features/Databases/DatabaseCreate/DatabaseCreate'; @@ -44,15 +42,11 @@ describe('database summary section', () => { ); }) ); - const history = createMemoryHistory(); - history.push('databases/create'); - const { getByTestId } = renderWithTheme( - - - , - { flags } - ); + const { getByTestId } = renderWithTheme(, { + MemoryRouter: { initialEntries: ['/databases/create'] }, + flags, + }); await waitForElementToBeRemoved(getByTestId(loadingTestId)); const selectedPlan = await waitFor( () => document.getElementById('g6-dedicated-2') as HTMLInputElement diff --git a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx index bd3a81dbef4..deb0eca784a 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/AccessControls.tsx @@ -1,8 +1,7 @@ -import { Button, Notice, Typography } from '@linode/ui'; +import { ActionsPanel, Button, Notice, Typography } from '@linode/ui'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; import { Table } from 'src/components/Table'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/AddAccessControlDrawer.tsx b/packages/manager/src/features/Databases/DatabaseDetail/AddAccessControlDrawer.tsx index 3fd55746724..dad94deab6e 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/AddAccessControlDrawer.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/AddAccessControlDrawer.tsx @@ -1,12 +1,11 @@ -import { Notice, Typography } from '@linode/ui'; +import { ActionsPanel, Drawer, Notice, Typography } from '@linode/ui'; import { useFormik } from 'formik'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; import { Link } from 'src/components/Link'; import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; +import { NotFound } from 'src/components/NotFound'; import { ACCESS_CONTROLS_DRAWER_TEXT, ACCESS_CONTROLS_DRAWER_TEXT_LEGACY, @@ -183,7 +182,12 @@ const AddAccessControlDrawer = (props: CombinedProps) => { const learnMoreLink = isDefaultDB ? LEARN_MORE_LINK : LEARN_MORE_LINK_LEGACY; return ( - + {error ? : null} {allowListErrors diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfiguration.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfiguration.style.ts new file mode 100644 index 00000000000..1e7d6347f28 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfiguration.style.ts @@ -0,0 +1,12 @@ +import { styled } from '@mui/material/styles'; + +import { StyledValueGrid } from '../DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; + +export const StyledConfigValue = styled(StyledValueGrid, { + label: 'StyledValueGrid', +})(({ theme }) => ({ + padding: `${theme.spacing(0.5)} + ${theme.spacing(1.9)} + ${theme.spacing(0.5)} + ${theme.spacing(0.8)}`, +})); diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfiguration.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfiguration.tsx new file mode 100644 index 00000000000..de94c2ff13d --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfiguration.tsx @@ -0,0 +1,98 @@ +import { Box, Button, Paper, Typography } from '@linode/ui'; +import Grid from '@mui/material/Grid2'; +import React from 'react'; + +import { Link } from 'src/components/Link'; + +import { formatConfigValue } from '../../utilities'; +import { + StyledGridContainer, + StyledLabelTypography, +} from '../DatabaseSummary/DatabaseSummaryClusterConfiguration.style'; +import { StyledConfigValue } from './DatabaseAdvancedConfiguration.style'; +import { DatabaseAdvancedConfigurationDrawer } from './DatabaseAdvancedConfigurationDrawer'; + +import type { Database } from '@linode/api-v4'; + +interface Props { + database: Database; + disabled?: boolean; +} + +export const DatabaseAdvancedConfiguration = ({ database }: Props) => { + const [ + advancedConfigurationDrawerOpen, + setAdvancedConfigurationDrawerOpen, + ] = React.useState(false); + + const engine = database.engine; + const engineConfigs = database.engine_config; + + return ( + + + + Advanced Configuration + + Advanced parameters to configure your database cluster.{' '} + {/* TODO: update link when it's ready */} + Learn more. + + + + + + {engineConfigs ? ( + + {Object.entries(engineConfigs).map(([key, value]) => + typeof value === 'object' ? ( + Object.entries(value!).map(([configLabel, configValue]) => ( + + + {`${engine}.${configLabel}`} + + + {formatConfigValue(String(configValue))} + + + )) + ) : ( + + + {`${engine}.${key}`} + + + {formatConfigValue(String(value))} + + + ) + )} + + ) : ( + + + No advanced configurations have been added. + + + )} + + setAdvancedConfigurationDrawerOpen(false)} + open={advancedConfigurationDrawerOpen} + /> + + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfigurationDrawer.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfigurationDrawer.tsx new file mode 100644 index 00000000000..1573c8d7b71 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfigurationDrawer.tsx @@ -0,0 +1,73 @@ +import { ActionsPanel, Divider, Drawer, Notice, Typography } from '@linode/ui'; +import React, { useState } from 'react'; + +import { Link } from 'src/components/Link'; +import { NotFound } from 'src/components/NotFound'; + +import { DatabaseConfigurationSelect } from './DatabaseConfigurationSelect'; + +import type { Database, DatabaseInstance } from '@linode/api-v4'; + +interface Props { + database: Database | DatabaseInstance; + onClose: () => void; + open: boolean; +} + +export const DatabaseAdvancedConfigurationDrawer = (props: Props) => { + const { onClose, open } = props; + + const [selectedConfig, setSelectedConfig] = useState(''); + + // const engineConfigs = database.engine_config; + // Placeholder for engine configurations (currently set to 'undefined' as the UI is not ready yet). + // The implementation will be updated in the second PR after UI work is completed. + const engineConfigs = undefined; + return ( + + + Advanced parameters to configure your database cluster. + + Learn more. + + + + There is no way to reset advanced configuration options to default. + Options that you add cannot be removed. Changing or adding some + options causes the service to restart. + + +
    + setSelectedConfig(config)} + value={selectedConfig} + /> + + + {!engineConfigs && ( + + No advanced configurations have been added. + + )} + + + +
    + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationSelect.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationSelect.tsx new file mode 100644 index 00000000000..056c4449695 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseConfigurationSelect.tsx @@ -0,0 +1,84 @@ +import { Autocomplete, Button, TextField } from '@linode/ui'; +import Grid from '@mui/material/Grid2'; +import React from 'react'; + +interface ConfigurationOption { + category: string; + description: string; + label: string; +} + +interface Props { + configurations: ConfigurationOption[]; + errorText: string | undefined; + onChange: (value: string) => void; + value: string; +} + +export const DatabaseConfigurationSelect = (props: Props) => { + const { configurations, errorText, onChange, value } = props; + + const selectedConfiguration = React.useMemo(() => { + return configurations.find((val) => val.label === value); + }, [value, configurations]); + + return ( + + + { + if (option.category === 'Other') { + return 'Other'; + } + return option.category; + }} + isOptionEqualToValue={(option, selectedValue) => + option.label === selectedValue.label + } + onChange={(_, selected) => { + onChange(selected.label); + }} + renderInput={(params) => ( + + )} + renderOption={(props, option) => ( +
  • +
    + {option.label} + {/* TODO: Add description if needed */} + {/* {option.description &&
    {option.description}
    } */} +
    +
  • + )} + autoHighlight + disableClearable + getOptionLabel={(option) => option.label} + label={''} + options={configurations} + sx={{ width: '336px' }} + value={selectedConfiguration} + /> +
    + + + +
    + ); +}; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx index 4e69e7d33ea..a1851e19b2f 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackups.tsx @@ -187,7 +187,13 @@ export const DatabaseBackups = (props: Props) => { /> )} - + Date diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx index d66b6e44298..fbfd1e8def8 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/DatabaseBackupsDialog.tsx @@ -1,10 +1,9 @@ -import { Dialog, Notice, Typography } from '@linode/ui'; +import { ActionsPanel, Dialog, Notice, Typography } from '@linode/ui'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { useState } from 'react'; import { useHistory } from 'react-router-dom'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { useRestoreFromBackupMutation } from 'src/queries/databases/databases'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/legacy/RestoreLegacyFromBackupDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/legacy/RestoreLegacyFromBackupDialog.tsx index 9605a131047..5618c19d30d 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/legacy/RestoreLegacyFromBackupDialog.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseBackups/legacy/RestoreLegacyFromBackupDialog.tsx @@ -5,7 +5,7 @@ import { useHistory } from 'react-router-dom'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { useLegacyRestoreFromBackupMutation } from 'src/queries/databases/databases'; -import { useProfile } from 'src/queries/profile/profile'; +import { useProfile } from '@linode/queries'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { formatDate } from 'src/utilities/formatDate'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.style.ts index 88d030365da..6a112fa933f 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.style.ts +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.style.ts @@ -1,6 +1,6 @@ import { Button } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { PlansPanel } from 'src/features/components/PlansPanel/PlansPanel'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx index e9e007c2279..3bb9f0af0ee 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.tsx @@ -2,15 +2,16 @@ import { Box, CircleProgress, Divider, + ErrorState, Notice, Paper, Typography, } from '@linode/ui'; +import { formatStorageUnits } from '@linode/utilities'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; import { determineInitialPlanCategoryTab } from 'src/features/components/PlansPanel/utils'; import { DatabaseNodeSelector } from 'src/features/Databases/DatabaseCreate/DatabaseNodeSelector'; @@ -18,9 +19,8 @@ import { DatabaseSummarySection } from 'src/features/Databases/DatabaseCreate/Da import { DatabaseResizeCurrentConfiguration } from 'src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResizeCurrentConfiguration'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import { typeLabelDetails } from 'src/features/Linodes/presentation'; -import { useDatabaseMutation } from 'src/queries/databases/databases'; import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; -import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; +import { useDatabaseMutation } from 'src/queries/databases/databases'; import { StyledGrid, diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.utils.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.utils.tsx index 4954019f1f0..7a41ef778e4 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.utils.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResize.utils.tsx @@ -1,4 +1,4 @@ -import { convertMegabytesTo } from 'src/utilities/unitConversions'; +import { convertMegabytesTo } from '@linode/utilities'; import type { PlanSelectionWithDatabaseType } from 'src/features/components/PlansPanel/types'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResizeCurrentConfiguration.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResizeCurrentConfiguration.tsx index 68a715688bd..094e961e934 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResizeCurrentConfiguration.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResizeCurrentConfiguration.tsx @@ -1,14 +1,12 @@ -import { Box, CircleProgress, TooltipIcon } from '@linode/ui'; +import { useRegionsQuery } from '@linode/queries'; +import { Box, CircleProgress, ErrorState, TooltipIcon } from '@linode/ui'; +import { convertMegabytesTo, formatStorageUnits } from '@linode/utilities'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { DatabaseEngineVersion } from 'src/features/Databases/DatabaseEngineVersion'; import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; import { useInProgressEvents } from 'src/queries/events/events'; -import { useRegionsQuery } from 'src/queries/regions/regions'; -import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; -import { convertMegabytesTo } from 'src/utilities/unitConversions'; import { DatabaseStatusDisplay } from '../DatabaseStatusDisplay'; import { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx index 674b14afe1a..50aef5ccbd0 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettings.tsx @@ -16,7 +16,7 @@ import { isDefaultDatabase, useIsDatabasesEnabled, } from 'src/features/Databases/utilities'; -import { useProfile } from 'src/queries/profile/profile'; +import { useProfile } from '@linode/queries'; import AccessControls from '../AccessControls'; import DatabaseSettingsDeleteClusterDialog from './DatabaseSettingsDeleteClusterDialog'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsResetPasswordDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsResetPasswordDialog.tsx index 299c28902e7..ac21f52b9fd 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsResetPasswordDialog.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsResetPasswordDialog.tsx @@ -1,7 +1,6 @@ -import { Notice, Typography } from '@linode/ui'; +import { ActionsPanel, Notice, Typography } from '@linode/ui'; import * as React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { useDatabaseCredentialsMutation } from 'src/queries/databases/databases'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsReviewUpdatesDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsReviewUpdatesDialog.tsx index 2220419e1ff..e2aa6358f9a 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsReviewUpdatesDialog.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsReviewUpdatesDialog.tsx @@ -1,9 +1,8 @@ -import { Typography } from '@linode/ui'; +import { ActionsPanel, Typography } from '@linode/ui'; import { useTheme } from '@mui/material'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { usePatchDatabaseMutation } from 'src/queries/databases/databases'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsSuspendClusterDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsSuspendClusterDialog.tsx index 8165093c13d..4d85222d24a 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsSuspendClusterDialog.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsSuspendClusterDialog.tsx @@ -1,9 +1,8 @@ -import { Checkbox, Notice, Typography } from '@linode/ui'; +import { ActionsPanel, Checkbox, Notice, Typography } from '@linode/ui'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { useSuspendDatabaseMutation } from 'src/queries/databases/databases'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; @@ -57,9 +56,8 @@ export const DatabaseSettingsSuspendClusterDialog = ( setHasConfirmed(false); }; - const suspendClusterCopy = `A suspended cluster stops working immediately and you won't be billed for it. - You can resume the clusters work within 180 days from its suspension. - After that time, the cluster will be deleted permanently.`; + const SUSPENDED_CLUSTER_COPY = + "A suspended cluster stops immediately and you won't be billed for it. You can resume the cluster within 180 days from its suspension. After that time, the cluster will be deleted permanently."; const actions = ( - {suspendClusterCopy} + {SUSPENDED_CLUSTER_COPY} ({ formControlDropdown: { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx index 204acfd1925..35536eb8601 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseStatusDisplay.tsx @@ -1,8 +1,8 @@ import { Typography } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import React from 'react'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; -import { capitalize } from 'src/utilities/capitalize'; import type { Event } from '@linode/api-v4'; import type { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx index b2dc91795f5..97a8f09d416 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummary.tsx @@ -1,5 +1,5 @@ import { Divider, Paper, Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { Link } from 'src/components/Link'; @@ -41,7 +41,12 @@ export const DatabaseSummary: React.FC = (props) => { return ( - + {isDatabasesV2GA ? ( ) : ( @@ -50,7 +55,12 @@ export const DatabaseSummary: React.FC = (props) => { )} - + {isDatabasesV2GA ? ( ) : ( diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts index 65a05c2b111..c6420ce478f 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.style.ts @@ -1,6 +1,6 @@ import { Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import Grid2 from '@mui/material/Unstable_Grid2/Grid2'; +import Grid2 from '@mui/material/Grid2'; export const StyledGridContainer = styled(Grid2, { label: 'StyledGridContainer', diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx index 1131b04d64e..454abcdbfb2 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.test.tsx @@ -23,13 +23,18 @@ const queryMocks = vi.hoisted(() => ({ useRegionsQuery: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/regions/regions', () => ({ +vi.mock('@linode/queries', async (importOriginal) => ({ + ...(await importOriginal()), useRegionsQuery: queryMocks.useRegionsQuery, })); -vi.mock('src/queries/databases/databases', () => ({ - useDatabaseTypesQuery: queryMocks.useDatabaseTypesQuery, -})); +vi.mock(import('src/queries/databases/databases'), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useDatabaseTypesQuery: queryMocks.useDatabaseTypesQuery, + }; +}); describe('DatabaseSummaryClusterConfiguration', () => { it('should display correctly for default db', async () => { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx index 8420ffc1c3d..2c235ba9542 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx @@ -1,5 +1,7 @@ +import { useRegionsQuery } from '@linode/queries'; import { TooltipIcon, Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2/Grid2'; +import { convertMegabytesTo, formatStorageUnits } from '@linode/utilities'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -12,9 +14,6 @@ import { import { DatabaseEngineVersion } from 'src/features/Databases/DatabaseEngineVersion'; import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; import { useInProgressEvents } from 'src/queries/events/events'; -import { useRegionsQuery } from 'src/queries/regions/regions'; -import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; -import { convertMegabytesTo } from 'src/utilities/unitConversions'; import type { Region } from '@linode/api-v4'; import type { @@ -73,35 +72,65 @@ export const DatabaseSummaryClusterConfiguration = (props: Props) => { Cluster Configuration - - + + Status - + - + Plan - + {formatStorageUnits(type.label)} - + Nodes - + {configuration} - + CPUs - + {type.vcpus} - + Engine - + { databaseVersion={database.version} /> - + Region - + {region?.label ?? database.region} - + RAM - + {type.memory / 1024} GB - + {database.total_disk_size_gb ? 'Total Disk Size' : 'Storage'} - + {database.total_disk_size_gb ? ( <> {database.total_disk_size_gb} GB diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts index 861a690394d..f654623b80d 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.style.ts @@ -46,7 +46,7 @@ export const useStyles = makeStyles()((theme: Theme) => ({ '& span': { font: theme.font.bold, }, - background: theme.tokens.interaction.Background.Secondary, + background: theme.tokens.alias.Interaction.Background.Secondary, border: `1px solid ${ theme.name === 'light' ? theme.tokens.color.Neutrals[40] diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.test.tsx index 9643fe9a5b0..d9c95bc0533 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.test.tsx @@ -22,9 +22,13 @@ const queryMocks = vi.hoisted(() => ({ useDatabaseCredentialsQuery: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/databases/databases', () => ({ - useDatabaseCredentialsQuery: queryMocks.useDatabaseCredentialsQuery, -})); +vi.mock(import('src/queries/databases/databases'), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useDatabaseCredentialsQuery: queryMocks.useDatabaseCredentialsQuery, + }; +}); describe('DatabaseSummaryConnectionDetails', () => { it('should display correctly for default db', async () => { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx index c077387da22..19b9d98820f 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryConnectionDetails.tsx @@ -1,6 +1,7 @@ import { getSSLFields } from '@linode/api-v4/lib/databases/databases'; import { Button, CircleProgress, TooltipIcon, Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2/Grid2'; +import { downloadFile } from '@linode/utilities'; +import Grid from '@mui/material/Grid2'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -8,7 +9,6 @@ import DownloadIcon from 'src/assets/icons/lke-download.svg'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { DB_ROOT_USERNAME } from 'src/constants'; import { useDatabaseCredentialsQuery } from 'src/queries/databases/databases'; -import { downloadFile } from 'src/utilities/downloadFile'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { @@ -181,17 +181,25 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { Connection Details - - + + Username - - {username} - - + {username} + Password - + {password} {showCredentials && credentialsLoading ? (
    @@ -228,16 +236,26 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { /> )} - + Database name - + {isLegacy ? database.engine : 'defaultdb'} - + Host - + {database.hosts?.primary ? ( <> {database.hosts?.primary} @@ -262,24 +280,39 @@ export const DatabaseSummaryConnectionDetails = (props: Props) => { )} - + {isLegacy ? 'Private Network Host' : 'Read-only Host'} - + {readOnlyHost()} - + Port - + {database.port} - + SSL - + {database.ssl_connection ? 'ENABLED' : 'DISABLED'} diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx index 4eb44b18f54..554f51d0eb2 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryClusterConfigurationLegacy.tsx @@ -1,4 +1,6 @@ +import { useRegionsQuery } from '@linode/queries'; import { Box, TooltipIcon, Typography } from '@linode/ui'; +import { convertMegabytesTo, formatStorageUnits } from '@linode/utilities'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -6,9 +8,6 @@ import { DatabaseStatusDisplay } from 'src/features/Databases/DatabaseDetail/Dat import { DatabaseEngineVersion } from 'src/features/Databases/DatabaseEngineVersion'; import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; import { useInProgressEvents } from 'src/queries/events/events'; -import { useRegionsQuery } from 'src/queries/regions/regions'; -import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; -import { convertMegabytesTo } from 'src/utilities/unitConversions'; import type { Region } from '@linode/api-v4'; import type { diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx index a0bca4e196f..af6add09add 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/legacy/DatabaseSummaryConnectionDetailsLegacy.tsx @@ -6,6 +6,7 @@ import { TooltipIcon, Typography, } from '@linode/ui'; +import { downloadFile } from '@linode/utilities'; import { useTheme } from '@mui/material'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -15,7 +16,6 @@ import DownloadIcon from 'src/assets/icons/lke-download.svg'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { DB_ROOT_USERNAME } from 'src/constants'; import { useDatabaseCredentialsQuery } from 'src/queries/databases/databases'; -import { downloadFile } from 'src/utilities/downloadFile'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import type { Database, SSLFields } from '@linode/api-v4/lib/databases/types'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx index f81eb723e5d..af844c1e1c6 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx @@ -1,18 +1,17 @@ -import { CircleProgress, Notice } from '@linode/ui'; +import { CircleProgress, ErrorState, Notice } from '@linode/ui'; +import { useEditableLabelState } from '@linode/utilities'; import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; import { matchPath, useHistory, useParams } from 'react-router-dom'; import { BetaChip } from 'src/components/BetaChip/BetaChip'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; 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 DatabaseLogo from 'src/features/Databases/DatabaseLanding/DatabaseLogo'; -import { useEditableLabelState } from 'src/hooks/useEditableLabelState'; import { useFlags } from 'src/hooks/useFlags'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { @@ -22,6 +21,8 @@ import { } from 'src/queries/databases/databases'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { DatabaseAdvancedConfiguration } from './DatabaseAdvancedConfiguration/DatabaseAdvancedConfiguration'; + import type { Engine } from '@linode/api-v4/lib/databases/types'; import type { APIError } from '@linode/api-v4/lib/types'; import type { Tab } from 'src/components/Tabs/TabLinkList'; @@ -91,6 +92,7 @@ export const DatabaseDetail = () => { const isDefault = database.platform === 'rdbms-default'; const isMonitorEnabled = isDefault && flags.dbaasV2MonitorMetrics?.enabled; + const isAdvancedConfigEnabled = isDefault && flags.databaseAdvancedConfig; const tabs: Tab[] = [ { @@ -109,6 +111,7 @@ export const DatabaseDetail = () => { const resizeIndex = isMonitorEnabled ? 3 : 2; const backupsIndex = isMonitorEnabled ? 2 : 1; + const settingsIndex = isMonitorEnabled ? 4 : 3; if (isMonitorEnabled) { tabs.splice(1, 0, { @@ -125,6 +128,13 @@ export const DatabaseDetail = () => { }); } + if (isAdvancedConfigEnabled) { + tabs.splice(5, 0, { + routeName: `/databases/${engine}/${id}/configs`, + title: 'Advanced Configuration', + }); + } + const getTabIndex = () => { const tabChoice = tabs.findIndex((tab) => Boolean(matchPath(tab.routeName, { path: location.pathname })) @@ -226,12 +236,17 @@ export const DatabaseDetail = () => { /> ) : null} - + + {isAdvancedConfigEnabled && ( + + + + )} {isDefault && } diff --git a/packages/manager/src/features/Databases/DatabaseEngineVersion.tsx b/packages/manager/src/features/Databases/DatabaseEngineVersion.tsx index 9b0357453d2..ebc17427ff4 100644 --- a/packages/manager/src/features/Databases/DatabaseEngineVersion.tsx +++ b/packages/manager/src/features/Databases/DatabaseEngineVersion.tsx @@ -1,7 +1,7 @@ import { styled } from '@mui/material'; import React from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from 'src/components/Link'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { @@ -44,6 +44,7 @@ export const DatabaseEngineVersion = (props: Props) => { {engineVersion} {isDefaultGA && hasUpdates && ( ({ useProfile: vi.fn().mockReturnValue({ data: { restricted: false } }), })); -vi.mock('src/queries/profile/profile', async () => { - const actual = await vi.importActual('src/queries/profile/profile'); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); return { ...actual, useProfile: queryMocks.useProfile, diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx index 0b62810b54b..e7fb07cc9a4 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.tsx @@ -1,10 +1,9 @@ -import { CircleProgress } from '@linode/ui'; +import { CircleProgress, ErrorState } from '@linode/ui'; import { Box } from '@mui/material'; import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { Tab } from 'src/components/Tabs/Tab'; @@ -148,9 +147,9 @@ const DatabaseLanding = () => { ); }; @@ -158,13 +157,13 @@ const DatabaseLanding = () => { const defaultTable = () => { return ( ); }; diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx index c02a0cdebf2..1cad7b863d9 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLandingTable.tsx @@ -21,7 +21,7 @@ import { useInProgressEvents } from 'src/queries/events/events'; import { DatabaseSettingsSuspendClusterDialog } from '../DatabaseDetail/DatabaseSettings/DatabaseSettingsSuspendClusterDialog'; import type { DatabaseInstance } from '@linode/api-v4/lib/databases'; -import type { Order } from 'src/hooks/useOrder'; +import type { Order } from '@linode/utilities'; const preferenceKey = 'databases'; diff --git a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx index 177df043eae..596ba14a695 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx @@ -1,4 +1,5 @@ import { Chip } from '@linode/ui'; +import { formatStorageUnits } from '@linode/utilities'; import * as React from 'react'; import { Hidden } from 'src/components/Hidden'; @@ -10,11 +11,9 @@ import { DatabaseEngineVersion } from 'src/features/Databases/DatabaseEngineVers import { DatabaseActionMenu } from 'src/features/Databases/DatabaseLanding/DatabaseActionMenu'; import { useIsDatabasesEnabled } from 'src/features/Databases/utilities'; import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; -import { useProfile } from 'src/queries/profile/profile'; -import { useRegionsQuery } from 'src/queries/regions/regions'; +import { useProfile, useRegionsQuery } from '@linode/queries'; import { isWithinDays, parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; -import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; import type { Event } from '@linode/api-v4'; import type { diff --git a/packages/manager/src/features/Databases/utilities.test.ts b/packages/manager/src/features/Databases/utilities.test.ts index 1c1ce93f2e7..2f251e5e373 100644 --- a/packages/manager/src/features/Databases/utilities.test.ts +++ b/packages/manager/src/features/Databases/utilities.test.ts @@ -7,6 +7,7 @@ import { databaseTypeFactory, } from 'src/factories'; import { + formatConfigValue, getDatabasesDescription, hasPendingUpdates, isDateOutsideBackup, @@ -47,9 +48,13 @@ const queryMocks = vi.hoisted(() => ({ useDatabaseTypesQuery: vi.fn().mockReturnValue({}), })); -vi.mock('src/queries/databases/databases', () => ({ - useDatabaseTypesQuery: queryMocks.useDatabaseTypesQuery, -})); +vi.mock(import('src/queries/databases/databases'), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useDatabaseTypesQuery: queryMocks.useDatabaseTypesQuery, + }; +}); describe('useIsDatabasesEnabled', () => { it('should return correctly for non V1/V2 user', async () => { @@ -559,3 +564,25 @@ describe('upgradableVersions', () => { expect(result).toBeUndefined(); }); }); + +describe('formatConfigValue', () => { + it('should return "Enabled" when configValue is "true"', () => { + const result = formatConfigValue('true'); + expect(result).toBe('Enabled'); + }); + + it('should return "Disabled" when configValue is "false"', () => { + const result = formatConfigValue('false'); + expect(result).toBe('Disabled'); + }); + + it('should return " -" when configValue is "undefined"', () => { + const result = formatConfigValue('undefined'); + expect(result).toBe(' - '); + }); + + it('should return the original configValue for other values', () => { + const result = formatConfigValue('+03:00'); + expect(result).toBe('+03:00'); + }); +}); diff --git a/packages/manager/src/features/Databases/utilities.ts b/packages/manager/src/features/Databases/utilities.ts index 76e27d0530e..1d4dda6805b 100644 --- a/packages/manager/src/features/Databases/utilities.ts +++ b/packages/manager/src/features/Databases/utilities.ts @@ -1,17 +1,17 @@ +import { useAccount } from '@linode/queries'; +import { isFeatureEnabledV2 } from '@linode/utilities'; import { DateTime } from 'luxon'; import { useFlags } from 'src/hooks/useFlags'; -import { useAccount } from 'src/queries/account/account'; import { useDatabaseTypesQuery } from 'src/queries/databases/databases'; -import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities'; import type { DatabaseEngine, + DatabaseFork, DatabaseInstance, Engine, PendingUpdates, } from '@linode/api-v4'; -import type { DatabaseFork } from '@linode/api-v4'; export interface IsDatabasesEnabled { isDatabasesEnabled: boolean; @@ -245,3 +245,22 @@ export const upgradableVersions = ( version: string, engines?: Pick[] ) => engines?.filter((e) => e.engine === engine && e.version > version); + +/** + * Formats the provided config value into a more user-friendly representation. + * - If the value is 'true', it will be displayed as 'Enabled'. + * - If the value is 'false', it will be displayed as 'Disabled'. + * - If the value is 'undefined', it will be displayed as ' - '. + * - Otherwise, the original value will be returned as-is. + * + * @param {string} configValue - The configuration value to be formatted. + * @returns {string} - The formatted string based on the configValue. + */ +export const formatConfigValue = (configValue: string) => + configValue === 'true' + ? 'Enabled' + : configValue === 'false' + ? 'Disabled' + : configValue === 'undefined' + ? ' - ' + : configValue; diff --git a/packages/manager/src/features/Domains/CloneDomainDrawer.tsx b/packages/manager/src/features/Domains/CloneDomainDrawer.tsx index 8c1c102dac1..18d780c8969 100644 --- a/packages/manager/src/features/Domains/CloneDomainDrawer.tsx +++ b/packages/manager/src/features/Domains/CloneDomainDrawer.tsx @@ -1,4 +1,7 @@ +import { useGrants, useProfile } from '@linode/queries'; import { + ActionsPanel, + Drawer, FormControlLabel, Notice, Radio, @@ -9,10 +12,8 @@ import { useNavigate } from '@tanstack/react-router'; import { useFormik } from 'formik'; import React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; +import { NotFound } from 'src/components/NotFound'; import { useCloneDomainMutation } from 'src/queries/domains'; -import { useGrants, useProfile } from 'src/queries/profile/profile'; import type { Domain } from '@linode/api-v4'; @@ -57,6 +58,7 @@ export const CloneDomainDrawer = (props: CloneDomainDrawerProps) => { return ( { )} - + { updateRecords={refetchRecords} /> - + Tags diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.tsx index ba6064e829e..9d68269cf54 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecordDrawer.tsx @@ -2,14 +2,13 @@ import { createDomainRecord, updateDomainRecord, } from '@linode/api-v4/lib/domains'; -import { Notice } from '@linode/ui'; +import { ActionsPanel, Drawer, Notice } from '@linode/ui'; +import { scrollErrorIntoViewV2 } from '@linode/utilities'; import * as React from 'react'; import { useForm } from 'react-hook-form'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; +import { NotFound } from 'src/components/NotFound'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; import { isValidCNAME, isValidDomainRecord } from '../../domainUtils'; import { @@ -258,6 +257,7 @@ export const DomainRecordDrawer = (props: DomainRecordDrawerProps) => { return ( { const noPermission = profile?.restricted && !grants?.global.add_domains; return ( - + {noPermission && ( You do not have permission to create new Domains. diff --git a/packages/manager/src/features/Domains/DomainsLanding.tsx b/packages/manager/src/features/Domains/DomainsLanding.tsx index 81e8ac001b5..8ac8c41594a 100644 --- a/packages/manager/src/features/Domains/DomainsLanding.tsx +++ b/packages/manager/src/features/Domains/DomainsLanding.tsx @@ -1,4 +1,4 @@ -import { Button, CircleProgress, Notice } from '@linode/ui'; +import { Button, CircleProgress, ErrorState, Notice } from '@linode/ui'; import { styled } from '@mui/material/styles'; import { useLocation, useNavigate, useParams } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; @@ -6,7 +6,6 @@ import * as React from 'react'; import { DeletionDialog } from 'src/components/DeletionDialog/DeletionDialog'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Hidden } from 'src/components/Hidden'; import { LandingHeader } from 'src/components/LandingHeader'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; @@ -25,8 +24,7 @@ import { useDomainsQuery, useUpdateDomainMutation, } from 'src/queries/domains'; -import { useLinodesQuery } from 'src/queries/linodes/linodes'; -import { useProfile } from 'src/queries/profile/profile'; +import { useLinodesQuery, useProfile } from '@linode/queries'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { CloneDomainDrawer } from './CloneDomainDrawer'; diff --git a/packages/manager/src/features/Domains/DownloadDNSZoneFileButton.test.tsx b/packages/manager/src/features/Domains/DownloadDNSZoneFileButton.test.tsx index 9e14564d3aa..997c0228a5e 100644 --- a/packages/manager/src/features/Domains/DownloadDNSZoneFileButton.test.tsx +++ b/packages/manager/src/features/Domains/DownloadDNSZoneFileButton.test.tsx @@ -1,7 +1,7 @@ +import { downloadFile } from '@linode/utilities'; import { fireEvent, waitFor } from '@testing-library/react'; import * as React from 'react'; -import { downloadFile } from 'src/utilities/downloadFile'; import { renderWithThemeAndRouter } from 'src/utilities/testHelpers'; import { DownloadDNSZoneFileButton } from './DownloadDNSZoneFileButton'; @@ -18,8 +18,8 @@ vi.mock('@linode/api-v4/lib/domains', async () => { }; }); -vi.mock('src/utilities/downloadFile', async () => { - const actual = await vi.importActual('src/utilities/downloadFile'); +vi.mock('@linode/utilities', async () => { + const actual = await vi.importActual('@linode/utilities'); return { ...actual, downloadFile: vi.fn(), diff --git a/packages/manager/src/features/Domains/DownloadDNSZoneFileButton.tsx b/packages/manager/src/features/Domains/DownloadDNSZoneFileButton.tsx index 25ec08bea09..68771f5c106 100644 --- a/packages/manager/src/features/Domains/DownloadDNSZoneFileButton.tsx +++ b/packages/manager/src/features/Domains/DownloadDNSZoneFileButton.tsx @@ -1,9 +1,8 @@ import { getDNSZoneFile } from '@linode/api-v4/lib/domains'; import { Button } from '@linode/ui'; +import { downloadFile } from '@linode/utilities'; import * as React from 'react'; -import { downloadFile } from 'src/utilities/downloadFile'; - type DownloadDNSZoneFileButtonProps = { domainId: number; domainLabel: string; diff --git a/packages/manager/src/features/Domains/EditDomainDrawer.tsx b/packages/manager/src/features/Domains/EditDomainDrawer.tsx index 2ba87d48ae4..362870f6aa7 100644 --- a/packages/manager/src/features/Domains/EditDomainDrawer.tsx +++ b/packages/manager/src/features/Domains/EditDomainDrawer.tsx @@ -1,4 +1,7 @@ +import { useGrants, useProfile } from '@linode/queries'; import { + ActionsPanel, + Drawer, FormControlLabel, Notice, Radio, @@ -8,12 +11,10 @@ import { import { useFormik } from 'formik'; import * as React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; +import { NotFound } from 'src/components/NotFound'; import { TagsInput } from 'src/components/TagsInput/TagsInput'; import { useUpdateDomainMutation } from 'src/queries/domains'; -import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getErrorMap } from 'src/utilities/errorUtils'; import { handleFormikBlur } from 'src/utilities/formikTrimUtil'; import { extendedIPToString, stringToExtendedIP } from 'src/utilities/ipUtils'; @@ -123,6 +124,7 @@ export const EditDomainDrawer = (props: EditDomainDrawerProps) => { return ( { /> ) : null} - + { selectedLinodes={state.linodes} /> - + handleCreateTransfer(payload, queryClient) diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx index 13f40092654..1478f1507c8 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/LinodeTransferTable.tsx @@ -7,8 +7,7 @@ import { SelectableTableRow } from 'src/components/SelectableTableRow/Selectable import { TableCell } from 'src/components/TableCell'; import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper'; import { usePagination } from 'src/hooks/usePagination'; -import { useLinodesQuery } from 'src/queries/linodes/linodes'; -import { useRegionsQuery } from 'src/queries/regions/regions'; +import { useLinodesQuery, useRegionsQuery } from '@linode/queries'; import { useSpecificTypes } from 'src/queries/types'; import { extendType } from 'src/utilities/extendType'; diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/TransferCheckoutBar.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/TransferCheckoutBar.tsx index 99402caca4d..1ce91ad8fed 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/TransferCheckoutBar.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/TransferCheckoutBar.tsx @@ -1,8 +1,7 @@ import { Typography } from '@linode/ui'; +import { pluralize } from '@linode/utilities'; import * as React from 'react'; -import { pluralize } from 'src/utilities/pluralize'; - import { StyledButton, StyledClose, diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferCancelDialog.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferCancelDialog.tsx index d32efff157a..51d54ec63b9 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferCancelDialog.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferCancelDialog.tsx @@ -1,10 +1,9 @@ import { cancelTransfer } from '@linode/api-v4/lib/entity-transfers'; -import { Notice, Typography } from '@linode/ui'; +import { ActionsPanel, Notice, Typography } from '@linode/ui'; import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { queryKey } from 'src/queries/entityTransfers'; import { sendEntityTransferCancelEvent } from 'src/utilities/analytics/customEventAnalytics'; diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.styles.ts b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.styles.ts index e020a92ae88..1027eedb78d 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.styles.ts +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.styles.ts @@ -1,8 +1,6 @@ -import { Typography } from '@linode/ui'; +import { ActionsPanel, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; - export const StyledActionsPanel = styled(ActionsPanel, { label: 'StyledActionsPanel', })({ diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx index 9e69baf3102..ca0da158557 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx @@ -1,23 +1,21 @@ import { acceptEntityTransfer } from '@linode/api-v4/lib/entity-transfers'; -import { Checkbox, CircleProgress, Notice } from '@linode/ui'; +import { Checkbox, CircleProgress, ErrorState, Notice } from '@linode/ui'; +import { capitalize, pluralize } from '@linode/utilities'; import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { TRANSFER_FILTERS, queryKey, useTransferQuery, } from 'src/queries/entityTransfers'; -import { useProfile } from 'src/queries/profile/profile'; +import { useProfile } from '@linode/queries'; import { sendEntityTransferReceiveEvent } from 'src/utilities/analytics/customEventAnalytics'; -import { capitalize } from 'src/utilities/capitalize'; import { parseAPIDate } from 'src/utilities/date'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { formatDate } from 'src/utilities/formatDate'; -import { pluralize } from 'src/utilities/pluralize'; import { countByEntity } from '../utilities'; import { diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/CreateTransferSuccessDialog.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/CreateTransferSuccessDialog.tsx index 666d21696ca..35af411f763 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/CreateTransferSuccessDialog.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/CreateTransferSuccessDialog.tsx @@ -1,4 +1,5 @@ import { Button, Tooltip, Typography } from '@linode/ui'; +import { pluralize } from '@linode/utilities'; import copy from 'copy-to-clipboard'; import { DateTime } from 'luxon'; import { update } from 'ramda'; @@ -10,7 +11,6 @@ import { sendEntityTransferCopyTokenEvent, } from 'src/utilities/analytics/customEventAnalytics'; import { parseAPIDate } from 'src/utilities/date'; -import { pluralize } from 'src/utilities/pluralize'; import { StyledCopyDiv, diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.styles.ts b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.styles.ts index acb35d9ca1e..d84133d7a47 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.styles.ts +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/TransferControls.styles.ts @@ -1,6 +1,6 @@ import { Button, TextField, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; // sm = 600, md = 960, lg = 1280 diff --git a/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx b/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx index b34d45e7ae1..9b7f4769953 100644 --- a/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx +++ b/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx @@ -1,12 +1,11 @@ import { StyledLinkButton } from '@linode/ui'; +import { capitalize, pluralize } from '@linode/utilities'; import * as React from 'react'; import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { Hidden } from 'src/components/Hidden'; import { MaskableText } from 'src/components/MaskableText/MaskableText'; import { TableCell } from 'src/components/TableCell'; -import { capitalize } from 'src/utilities/capitalize'; -import { pluralize } from 'src/utilities/pluralize'; import { StyledCopyTooltip, diff --git a/packages/manager/src/features/EntityTransfers/TransfersTable.tsx b/packages/manager/src/features/EntityTransfers/TransfersTable.tsx index fdec838e49e..0e2fbbeaa04 100644 --- a/packages/manager/src/features/EntityTransfers/TransfersTable.tsx +++ b/packages/manager/src/features/EntityTransfers/TransfersTable.tsx @@ -1,4 +1,5 @@ import { Accordion } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import * as React from 'react'; import { Hidden } from 'src/components/Hidden'; @@ -8,7 +9,6 @@ import { TableCell } from 'src/components/TableCell'; import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; -import { capitalize } from 'src/utilities/capitalize'; import { ConfirmTransferCancelDialog } from './EntityTransfersLanding/ConfirmTransferCancelDialog'; import { TransferDetailsDialog } from './EntityTransfersLanding/TransferDetailsDialog'; diff --git a/packages/manager/src/features/EntityTransfers/utilities.ts b/packages/manager/src/features/EntityTransfers/utilities.ts index 50ea6e3f71f..89620df66ce 100644 --- a/packages/manager/src/features/EntityTransfers/utilities.ts +++ b/packages/manager/src/features/EntityTransfers/utilities.ts @@ -1,6 +1,5 @@ import { TransferEntities } from '@linode/api-v4/lib/entity-transfers'; - -import { capitalize } from 'src/utilities/capitalize'; +import { capitalize } from '@linode/utilities'; // Return the count of each transferred entity by type, for reporting analytics. // E.g. { linodes: [ 1234 ], domains: [ 2345, 3456 ]} -> "Linodes: 1, Domains: 2" diff --git a/packages/manager/src/features/Events/EventRow.tsx b/packages/manager/src/features/Events/EventRow.tsx index 87cc75613e9..8c22fa361c8 100644 --- a/packages/manager/src/features/Events/EventRow.tsx +++ b/packages/manager/src/features/Events/EventRow.tsx @@ -9,7 +9,7 @@ import { Hidden } from 'src/components/Hidden'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { TextTooltip } from 'src/components/TextTooltip'; -import { useProfile } from 'src/queries/profile/profile'; +import { useProfile } from '@linode/queries'; import { formatProgressEvent, diff --git a/packages/manager/src/features/Events/asyncToasts.tsx b/packages/manager/src/features/Events/asyncToasts.tsx index 5c3cdc9553e..93de10bf20a 100644 --- a/packages/manager/src/features/Events/asyncToasts.tsx +++ b/packages/manager/src/features/Events/asyncToasts.tsx @@ -80,6 +80,7 @@ export const toasts: Toasts = { linode_clone: createToast({ failure: true, success: true }), linode_migrate: createToast({ failure: true, success: true }), linode_migrate_datacenter: createToast({ failure: true, success: true }), + linode_rebuild: 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 }), diff --git a/packages/manager/src/features/Events/factories/firewall.tsx b/packages/manager/src/features/Events/factories/firewall.tsx index c9db016c1e5..140dd563b4d 100644 --- a/packages/manager/src/features/Events/factories/firewall.tsx +++ b/packages/manager/src/features/Events/factories/firewall.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { formattedTypes } from 'src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding'; +import { formattedTypes } from 'src/features/Firewalls/FirewallDetail/Devices/constants'; import { EventLink } from '../EventLink'; diff --git a/packages/manager/src/features/Events/factories/linode.tsx b/packages/manager/src/features/Events/factories/linode.tsx index 2e32e5dfff4..41f1f269aa9 100644 --- a/packages/manager/src/features/Events/factories/linode.tsx +++ b/packages/manager/src/features/Events/factories/linode.tsx @@ -1,9 +1,9 @@ +import { formatStorageUnits } from '@linode/utilities'; import * as React from 'react'; import { Link } from 'src/components/Link'; -import { useLinodeQuery } from 'src/queries/linodes/linodes'; +import { useLinodeQuery } from '@linode/queries'; import { useTypeQuery } from 'src/queries/types'; -import { formatStorageUnits } from 'src/utilities/formatStorageUnits'; import { EventLink } from '../EventLink'; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx index 2e14b866959..b45d1cc0291 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx @@ -12,7 +12,24 @@ const props = { onClose, open: true, }; + +const queryMocks = vi.hoisted(() => ({ + useParams: vi.fn().mockReturnValue({}), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: queryMocks.useParams, + }; +}); + describe('AddLinodeDrawer', () => { + beforeEach(() => { + queryMocks.useParams.mockReturnValue({ id: '1' }); + }); + it('should contain helper text', () => { const { getByText } = renderWithTheme(); expect(getByText(helperText)).toBeInTheDocument(); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx index ba5f2a15afb..c2059a723cd 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx @@ -1,19 +1,19 @@ -import { Notice } from '@linode/ui'; +import { + useAddFirewallDeviceMutation, + useAllFirewallsQuery, + useGrants, + useProfile, +} from '@linode/queries'; +import { ActionsPanel, Drawer, Notice } from '@linode/ui'; import { useTheme } from '@mui/material'; +import { useParams } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useParams } from 'react-router-dom'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; import { Link } from 'src/components/Link'; +import { NotFound } from 'src/components/NotFound'; import { SupportLink } from 'src/components/SupportLink'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; -import { - useAddFirewallDeviceMutation, - useAllFirewallsQuery, -} from 'src/queries/firewalls'; -import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getEntityIdsByPermission } from 'src/utilities/grants'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; @@ -29,7 +29,7 @@ interface Props { export const AddLinodeDrawer = (props: Props) => { const { helperText, onClose, open } = props; - const { id } = useParams<{ id: string }>(); + const { id } = useParams({ strict: false }); const { enqueueSnackbar } = useSnackbar(); @@ -176,6 +176,7 @@ export const AddLinodeDrawer = (props: Props) => { setLocalError(undefined); onClose(); }} + NotFoundComponent={NotFound} open={open} title={`Add Linode to Firewall: ${firewall?.label}`} > diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx index 36d229be748..c537eaa1497 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx @@ -13,7 +13,23 @@ const props = { open: true, }; +const queryMocks = vi.hoisted(() => ({ + useParams: vi.fn().mockReturnValue({}), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useParams: queryMocks.useParams, + }; +}); + describe('AddNodeBalancerDrawer', () => { + beforeEach(() => { + queryMocks.useParams.mockReturnValue({ id: '1' }); + }); + it('should contain helper text', () => { const { getByText } = renderWithTheme(); expect(getByText(helperText)).toBeInTheDocument(); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx index 435fd687795..6d5bc6e381e 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx @@ -1,20 +1,20 @@ -import { Notice } from '@linode/ui'; +import { + useAddFirewallDeviceMutation, + useAllFirewallsQuery, + useGrants, + useProfile, +} from '@linode/queries'; +import { ActionsPanel, Drawer, Notice } from '@linode/ui'; import { useTheme } from '@mui/material'; +import { useParams } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { useParams } from 'react-router-dom'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; import { Link } from 'src/components/Link'; +import { NotFound } from 'src/components/NotFound'; import { SupportLink } from 'src/components/SupportLink'; import { FIREWALL_LIMITS_CONSIDERATIONS_LINK } from 'src/constants'; import { NodeBalancerSelect } from 'src/features/NodeBalancers/NodeBalancerSelect'; -import { - useAddFirewallDeviceMutation, - useAllFirewallsQuery, -} from 'src/queries/firewalls'; -import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { getEntityIdsByPermission } from 'src/utilities/grants'; import { sanitizeHTML } from 'src/utilities/sanitizeHTML'; @@ -30,7 +30,7 @@ interface Props { export const AddNodebalancerDrawer = (props: Props) => { const { helperText, onClose, open } = props; const { enqueueSnackbar } = useSnackbar(); - const { id } = useParams<{ id: string }>(); + const { id } = useParams({ strict: false }); const { data: grants } = useGrants(); const { data: profile } = useProfile(); const isRestrictedUser = Boolean(profile?.restricted); @@ -180,6 +180,7 @@ export const AddNodebalancerDrawer = (props: Props) => { setLocalError(undefined); onClose(); }} + NotFoundComponent={NotFound} open={open} title={`Add Nodebalancer to Firewall: ${firewall?.label}`} > diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx index ff489aae8f8..bd80e4a2859 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx @@ -3,29 +3,26 @@ import * as React from 'react'; import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; export interface ActionHandlers { - triggerRemoveDevice: (deviceID: number, label: string) => void; + handleRemoveDevice: (device: FirewallDevice) => void; } -import type { FirewallDeviceEntityType } from '@linode/api-v4'; +import type { FirewallDevice } from '@linode/api-v4'; export interface FirewallDeviceActionMenuProps extends ActionHandlers { - deviceEntityID: number; - deviceID: number; - deviceLabel: string; - deviceType: FirewallDeviceEntityType; + device: FirewallDevice; disabled: boolean; } export const FirewallDeviceActionMenu = React.memo( (props: FirewallDeviceActionMenuProps) => { - const { deviceID, deviceLabel, disabled, triggerRemoveDevice } = props; + const { device, disabled, handleRemoveDevice } = props; return ( triggerRemoveDevice(deviceID, deviceLabel)} + onClick={() => handleRemoveDevice(device)} /> ); } diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx index 102a24c7cb5..5ce6a4024d0 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx @@ -1,19 +1,47 @@ -import { fireEvent } from '@testing-library/react'; -import { createMemoryHistory } from 'history'; +import { fireEvent, waitFor } from '@testing-library/react'; import * as React from 'react'; -import { Router } from 'react-router-dom'; import { firewallDeviceFactory } from 'src/factories'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; - +import { HttpResponse, http, server } from 'src/mocks/testServer'; import { - FirewallDeviceLanding, - FirewallDeviceLandingProps, -} from './FirewallDeviceLanding'; + renderWithTheme, + renderWithThemeAndRouter, +} from 'src/utilities/testHelpers'; + +import { FirewallDeviceLanding } from './FirewallDeviceLanding'; +import type { FirewallDeviceLandingProps } from './FirewallDeviceLanding'; import type { FirewallDeviceEntityType } from '@linode/api-v4'; +const queryMocks = vi.hoisted(() => ({ + useLocation: vi.fn().mockReturnValue({}), + useNavigate: vi.fn(() => vi.fn()), + useOrderV2: vi.fn().mockReturnValue({ + handleOrderChange: vi.fn(), + }), + useParams: vi.fn().mockReturnValue({}), + useSearch: vi.fn().mockReturnValue({}), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useLocation: queryMocks.useLocation, + useNavigate: queryMocks.useNavigate, + useParams: queryMocks.useParams, + useSearch: queryMocks.useSearch, + }; +}); + +vi.mock('src/hooks/useOrderV2', async () => { + const actual = await vi.importActual('src/hooks/useOrderV2'); + return { + ...actual, + useOrderV2: queryMocks.useOrderV2, + }; +}); + const baseProps = ( type: FirewallDeviceEntityType ): FirewallDeviceLandingProps => ({ @@ -34,6 +62,14 @@ services.forEach((service: FirewallDeviceEntityType) => { const serviceName = service === 'linode' ? 'Linode' : 'NodeBalancer'; describe(`Firewall ${serviceName} landing page`, () => { + beforeEach(() => { + queryMocks.useLocation.mockReturnValue({ + pathname: '/firewalls/1/linodes', + }); + queryMocks.useParams.mockReturnValue({ + id: '1', + }); + }); const props = [baseProps(service), disabledProps(service)]; props.forEach((prop) => { @@ -62,6 +98,7 @@ services.forEach((service: FirewallDeviceEntityType) => { expect(addButton).toHaveAttribute('aria-disabled', 'true'); }); + it('should contain permission notice when disabled', () => { const { getByRole } = renderWithTheme( @@ -80,17 +117,26 @@ services.forEach((service: FirewallDeviceEntityType) => { expect(addButton).toHaveAttribute('aria-disabled', 'false'); }); - it(`should navigate to Add ${serviceName} To Firewall drawer when enabled`, () => { - const history = createMemoryHistory(); - const { getByTestId } = renderWithTheme( - - - + + it(`should navigate to Add ${serviceName} To Firewall drawer when enabled`, async () => { + const mockNavigate = vi.fn(); + queryMocks.useNavigate.mockReturnValue(mockNavigate); + + const { getByTestId } = await renderWithThemeAndRouter( + , + { + initialRoute: `/firewalls/1/${service}`, + } ); const addButton = getByTestId('add-device-button'); fireEvent.click(addButton); - const baseUrl = '/'; - expect(history.location.pathname).toBe(baseUrl + '/add'); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith({ + params: { id: '1' }, + to: `/firewalls/$id/${service}s/add`, + }); + }); }); } }); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx index 7b4dd00f56c..ec8e183aab1 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx @@ -1,15 +1,14 @@ import { Button, Notice, Typography } from '@linode/ui'; -import { useTheme } from '@mui/material/styles'; -import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; +import { styled, useTheme } from '@mui/material/styles'; +import { useLocation, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory, useLocation, useRouteMatch } from 'react-router-dom'; import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; -import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; import { AddLinodeDrawer } from './AddLinodeDrawer'; import { AddNodebalancerDrawer } from './AddNodebalancerDrawer'; +import { formattedTypes } from './constants'; import { FirewallDeviceTable } from './FirewallDeviceTable'; import { RemoveDeviceDialog } from './RemoveDeviceDialog'; @@ -22,83 +21,60 @@ export interface FirewallDeviceLandingProps { type: FirewallDeviceEntityType; } -export const formattedTypes: Record = { - interface: 'Interface', // @TODO Linode Interface: double check this when working on UI tickets - linode: 'Linode', - nodebalancer: 'NodeBalancer', -}; - export const FirewallDeviceLanding = React.memo( (props: FirewallDeviceLandingProps) => { const { disabled, firewallId, firewallLabel, type } = props; - - const { data: allDevices, error, isLoading } = useAllFirewallDevicesQuery( - firewallId - ); - const theme = useTheme(); - - const history = useHistory(); - const routeMatch = useRouteMatch(); + const navigate = useNavigate(); const location = useLocation(); - const helperText = 'Assign one or more services to this firewall. You can add services later if you want to customize your rules first.'; - React.useEffect(() => { - if (location.pathname.endsWith('add')) { - setDeviceDrawerOpen(true); - } - }, [location.pathname]); - - const devices = - allDevices?.filter((device) => device.entity.type === type) || []; - - const [filteredDevices, setFilteredDevices] = React.useState< - FirewallDevice[] - >([]); - - React.useEffect(() => { - setFilteredDevices(devices); - }, [allDevices]); - - const [ - isRemoveDeviceDialogOpen, - setIsRemoveDeviceDialogOpen, - ] = React.useState(false); - - const [selectedDeviceId, setSelectedDeviceId] = React.useState(-1); - - const selectedDevice = filteredDevices?.find( - (device) => device.id === selectedDeviceId - ); - - const [addDeviceDrawerOpen, setDeviceDrawerOpen] = React.useState( - false - ); - const handleClose = () => { - setDeviceDrawerOpen(false); - history.push(routeMatch.url); + navigate({ + params: { id: String(firewallId) }, + to: + type === 'linode' + ? '/firewalls/$id/linodes' + : '/firewalls/$id/nodebalancers', + }); }; const handleOpen = () => { - setDeviceDrawerOpen(true); - history.push(routeMatch.url + '/add'); + navigate({ + params: { id: String(firewallId) }, + to: + type === 'linode' + ? '/firewalls/$id/linodes/add' + : '/firewalls/$id/nodebalancers/add', + }); }; const [searchText, setSearchText] = React.useState(''); const filter = (value: string) => { setSearchText(value); - const filtered = devices?.filter((device) => { - return device.entity.label.toLowerCase().includes(value.toLowerCase()); - }); - setFilteredDevices(filtered ?? []); }; + const [device, setDevice] = React.useState( + undefined + ); const formattedType = formattedTypes[type]; + // If the user initiates a history -/+ to a /remove route and the device is not found, + // push navigation to the appropriate /linodes or /nodebalancers route. + React.useEffect(() => { + if (!device && location.pathname.endsWith('remove')) { + navigate({ + params: { id: String(firewallId) }, + to: + type === 'linode' + ? '/firewalls/$id/linodes' + : '/firewalls/$id/nodebalancers', + }); + } + }, [device, location.pathname, firewallId, type, navigate]); + return ( <> {disabled ? ( @@ -120,10 +96,12 @@ export const FirewallDeviceLanding = React.memo( A {formattedType} can only be assigned to a single Firewall. { - setSelectedDeviceId(id); - setIsRemoveDeviceDialogOpen(true); + handleRemoveDevice={(device) => { + setDevice(device); + navigate({ + params: { id: String(firewallId) }, + to: + type === 'linode' + ? '/firewalls/$id/linodes/remove' + : '/firewalls/$id/nodebalancers/remove', + }); }} deviceType={type} - devices={filteredDevices ?? []} disabled={disabled} - error={error ?? undefined} - loading={isLoading} + firewallId={firewallId} + type={type} /> {type === 'linode' ? ( ) : ( )} + navigate({ + params: { id: String(firewallId) }, + to: + type === 'linode' + ? '/firewalls/$id/linodes' + : '/firewalls/$id/nodebalancers', + }) + } + device={device} firewallId={firewallId} firewallLabel={firewallLabel} - onClose={() => setIsRemoveDeviceDialogOpen(false)} onService={undefined} - open={isRemoveDeviceDialogOpen} + open={location.pathname.endsWith('remove')} /> ); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx index 7b1cfcb8fc6..91d47cbbb80 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx @@ -1,30 +1,27 @@ import * as React from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from 'src/components/Link'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { - FirewallDeviceActionMenu, - FirewallDeviceActionMenuProps, -} from './FirewallDeviceActionMenu'; +import { FirewallDeviceActionMenu } from './FirewallDeviceActionMenu'; + +import type { FirewallDeviceActionMenuProps } from './FirewallDeviceActionMenu'; export const FirewallDeviceRow = React.memo( (props: FirewallDeviceActionMenuProps) => { - const { deviceEntityID, deviceID, deviceLabel, deviceType } = props; + const { device } = props; + const { id, label, type } = device.entity; return ( - + - - {deviceLabel} + + {label} - + ); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.test.tsx index f56aa9160f1..614baa7be53 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.test.tsx @@ -12,11 +12,33 @@ const devices = ['linode', 'nodebalancer']; const props = (type: FirewallDeviceEntityType): FirewallDeviceTableProps => ({ deviceType: type, - devices: firewallDeviceFactory.buildList(2), disabled: false, - error: undefined, - loading: false, - triggerRemoveDevice: vi.fn(), + firewallId: 1, + handleRemoveDevice: vi.fn(), + type, +}); + +const queryMocks = vi.hoisted(() => ({ + useAllFirewallDevicesQuery: vi.fn().mockReturnValue({}), + useNavigate: vi.fn(() => vi.fn()), + useSearch: vi.fn().mockReturnValue({}), +})); + +vi.mock('@tanstack/react-router', async () => { + const actual = await vi.importActual('@tanstack/react-router'); + return { + ...actual, + useNavigate: queryMocks.useNavigate, + useSearch: queryMocks.useSearch, + }; +}); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAllFirewallDevicesQuery: queryMocks.useAllFirewallDevicesQuery, + }; }); devices.forEach((device: FirewallDeviceEntityType) => { @@ -28,13 +50,24 @@ devices.forEach((device: FirewallDeviceEntityType) => { const table = getByRole('table'); expect(table).toBeInTheDocument(); }); - }); - it('should contain two rows', () => { - const { getAllByRole } = renderWithTheme( - - ); - const rows = getAllByRole('row'); - expect(rows.length - 1).toBe(2); + it('should contain two rows', () => { + queryMocks.useAllFirewallDevicesQuery.mockReturnValue({ + data: firewallDeviceFactory.buildList(2, { + entity: { + id: 1, + label: `test-${device}`, + type: device, + }, + }), + error: null, + isLoading: false, + }); + const { getAllByRole } = renderWithTheme( + + ); + const rows = getAllByRole('row'); + expect(rows.length - 1).toBe(2); + }); }); }); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx index 0e001a071c6..90f784bc354 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx @@ -1,7 +1,6 @@ +import { useAllFirewallDevicesQuery } from '@linode/queries'; import * as React from 'react'; -import OrderBy from 'src/components/OrderBy'; -import Paginate from 'src/components/Paginate'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; @@ -9,35 +8,39 @@ import { TableContentWrapper } from 'src/components/TableContentWrapper/TableCon import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; +import { useOrderV2 } from 'src/hooks/useOrderV2'; +import { usePaginationV2 } from 'src/hooks/usePaginationV2'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { formattedTypes } from './FirewallDeviceLanding'; +import { formattedTypes } from './constants'; import { FirewallDeviceRow } from './FirewallDeviceRow'; -import type { FirewallDeviceEntityType } from '@linode/api-v4'; -import type { FirewallDevice } from '@linode/api-v4/lib/firewalls/types'; -import type { APIError } from '@linode/api-v4/lib/types'; +import type { FirewallDevice, FirewallDeviceEntityType } from '@linode/api-v4'; export interface FirewallDeviceTableProps { deviceType: FirewallDeviceEntityType; - devices: FirewallDevice[]; disabled: boolean; - error?: APIError[]; - loading: boolean; - triggerRemoveDevice: (deviceID: number) => void; + firewallId: number; + handleRemoveDevice: (device: FirewallDevice) => void; + type: FirewallDeviceEntityType; } export const FirewallDeviceTable = React.memo( (props: FirewallDeviceTableProps) => { const { deviceType, - devices, disabled, - error, - loading, - triggerRemoveDevice, + firewallId, + handleRemoveDevice, + type, } = props; + const { data: allDevices, error, isLoading } = useAllFirewallDevicesQuery( + firewallId + ); + const devices = + allDevices?.filter((device) => device.entity.type === type) || []; + const _error = error ? getAPIErrorOrDefault( error, @@ -47,67 +50,77 @@ export const FirewallDeviceTable = React.memo( const ariaLabel = `List of ${formattedTypes[deviceType]}s attached to this firewall`; + const { + handleOrderChange, + order, + orderBy, + sortedData: sortedDevices, + } = useOrderV2({ + data: devices, + initialRoute: { + defaultOrder: { + order: 'asc', + orderBy: `entity:label`, + }, + from: + deviceType === 'linode' + ? '/firewalls/$id/linodes' + : '/firewalls/$id/nodebalancers', + }, + preferenceKey: `${deviceType}s-order`, + }); + + const pagination = usePaginationV2({ + currentRoute: + deviceType === 'linode' + ? '/firewalls/$id/linodes' + : '/firewalls/$id/nodebalancers', + preferenceKey: `${deviceType}s-pagination`, + }); + return ( - - {({ data: orderedData, handleOrderChange, order, orderBy }) => ( - - {({ - count, - data: paginatedAndOrderedData, - handlePageChange, - handlePageSizeChange, - page, - pageSize, - }) => ( - <> - - - - - {formattedTypes[deviceType]} - - - - - - {paginatedAndOrderedData.map((thisDevice) => ( - - ))} - - -
    - + + + + + {formattedTypes[deviceType]} + + + + + + {sortedDevices?.map((thisDevice) => ( + - - )} - - )} - + ))} + + +
    + + ); } ); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx index a6caa5e2acf..f598a00ea5d 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx @@ -1,13 +1,14 @@ -import { Typography } from '@linode/ui'; +import { ActionsPanel, Typography } from '@linode/ui'; import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { useRemoveFirewallDeviceMutation } from 'src/queries/firewalls'; -import { linodeQueries } from 'src/queries/linodes/linodes'; -import { nodebalancerQueries } from 'src/queries/nodebalancers'; +import { + useRemoveFirewallDeviceMutation, + linodeQueries, + nodebalancerQueries, +} from '@linode/queries'; import type { FirewallDevice } from '@linode/api-v4'; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/constants.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/constants.ts new file mode 100644 index 00000000000..9234255a29c --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/constants.ts @@ -0,0 +1,7 @@ +import type { FirewallDeviceEntityType } from '@linode/api-v4'; + +export const formattedTypes: Record = { + interface: 'Interface', // @TODO Linode Interface: double check this when working on UI tickets + linode: 'Linode', + nodebalancer: 'NodeBalancer', +}; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx index f9277c7c6d9..3888e665cb8 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.test.tsx @@ -3,17 +3,16 @@ import * as React from 'react'; import { renderWithTheme } from 'src/utilities/testHelpers'; -import { - FirewallRuleActionMenu, - FirewallRuleActionMenuProps, -} from './FirewallRuleActionMenu'; +import { FirewallRuleActionMenu } from './FirewallRuleActionMenu'; + +import type { FirewallRuleActionMenuProps } from './FirewallRuleActionMenu'; const props: FirewallRuleActionMenuProps = { disabled: false, + handleCloneFirewallRule: vi.fn(), + handleDeleteFirewallRule: vi.fn(), + handleOpenRuleDrawerForEditing: vi.fn(), idx: 1, - triggerCloneFirewallRule: vi.fn(), - triggerDeleteFirewallRule: vi.fn(), - triggerOpenRuleDrawerForEditing: vi.fn(), }; describe('Firewall rule action menu', () => { diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx index 8ea1e58f141..3f3313022a3 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleActionMenu.tsx @@ -1,20 +1,22 @@ -import { Theme, useTheme } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import * as React from 'react'; -import { +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; +import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; + +import type { Theme } from '@mui/material/styles'; +import type { Action, - ActionMenu, ActionMenuProps, } from 'src/components/ActionMenu/ActionMenu'; -import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; export interface FirewallRuleActionMenuProps extends Partial { disabled: boolean; + handleCloneFirewallRule: (idx: number) => void; + handleDeleteFirewallRule: (idx: number) => void; + handleOpenRuleDrawerForEditing: (idx: number) => void; idx: number; - triggerCloneFirewallRule: (idx: number) => void; - triggerDeleteFirewallRule: (idx: number) => void; - triggerOpenRuleDrawerForEditing: (idx: number) => void; } export const FirewallRuleActionMenu = React.memo( @@ -24,10 +26,10 @@ export const FirewallRuleActionMenu = React.memo( const { disabled, + handleCloneFirewallRule, + handleDeleteFirewallRule, + handleOpenRuleDrawerForEditing, idx, - triggerCloneFirewallRule, - triggerDeleteFirewallRule, - triggerOpenRuleDrawerForEditing, ...actionMenuProps } = props; @@ -35,21 +37,21 @@ export const FirewallRuleActionMenu = React.memo( { disabled, onClick: () => { - triggerOpenRuleDrawerForEditing(idx); + handleOpenRuleDrawerForEditing(idx); }, title: 'Edit', }, { disabled, onClick: () => { - triggerCloneFirewallRule(idx); + handleCloneFirewallRule(idx); }, title: 'Clone', }, { disabled, onClick: () => { - triggerDeleteFirewallRule(idx); + handleDeleteFirewallRule(idx); }, title: 'Delete', }, diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 2cea8507824..114b727c520 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -1,9 +1,9 @@ -import { Typography } from '@linode/ui'; +import { Drawer, Typography } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import { Formik } from 'formik'; import * as React from 'react'; -import { Drawer } from 'src/components/Drawer'; -import { capitalize } from 'src/utilities/capitalize'; +import { NotFound } from 'src/components/NotFound'; import { formValueToIPs, @@ -117,7 +117,12 @@ export const FirewallRuleDrawer = React.memo( }; return ( - +
    @@ -231,8 +229,4 @@ const FirewallLanding = () => { ); }; -export const firewallLandingLazyRoute = createLazyRoute('/firewalls')({ - component: FirewallLanding, -}); - export default React.memo(FirewallLanding); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.styles.ts b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.styles.ts index 6ac8ff5514f..b4a2b22d640 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.styles.ts +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.styles.ts @@ -1,11 +1,4 @@ import { styled } from '@mui/material/styles'; -import { Link } from 'react-router-dom'; - -export const StyledDevicesLink = styled(Link, { - label: 'StyledDevicesLink', -})(() => ({ - display: 'inline-block', -})); export const StyledDivWrapper = styled('div', { label: 'StyledDivWrapper', diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx index e24fcdc7924..edd7962269f 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.test.tsx @@ -1,3 +1,4 @@ +import { capitalize } from '@linode/utilities'; import { render } from '@testing-library/react'; import * as React from 'react'; @@ -6,7 +7,6 @@ import { firewallDeviceFactory, firewallFactory, } from 'src/factories/firewalls'; -import { capitalize } from 'src/utilities/capitalize'; import { mockMatchMedia, renderWithTheme, diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx index a2ccffc2190..f5b2c02701c 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx @@ -1,3 +1,4 @@ +import { capitalize } from '@linode/utilities'; import React from 'react'; import { Hidden } from 'src/components/Hidden'; @@ -5,7 +6,6 @@ import { Link } from 'src/components/Link'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { capitalize } from 'src/utilities/capitalize'; import { FirewallActionMenu } from './FirewallActionMenu'; diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/PublicTemplateRules.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/PublicTemplateRules.tsx new file mode 100644 index 00000000000..e6972d2207e --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallLanding/PublicTemplateRules.tsx @@ -0,0 +1,88 @@ +import { Box, List, ListItem, Typography } from '@linode/ui'; +import * as React from 'react'; + +import { STRENGTHEN_TEMPLATE_RULES } from './constants'; + +import type { Theme } from '@mui/material'; + +export const PublicTemplateRules = () => { + return ( + <> + ({ marginTop: theme.spacing(3) })}> + Allows for login with SSH, and regular networking control data. + + ({ marginTop: theme.spacing(2) })}> + {STRENGTHEN_TEMPLATE_RULES} + + ({ + backgroundColor: theme.tokens.alias.Background.Neutral, + marginTop: theme.spacing(2), + padding: theme.spacing(2), + })} + data-testid="public-template-info" + > + {sharedTemplateRules} + + {sharedTemplatePolicies} + + ); +}; + +const templateRuleStyling = (theme: Theme) => ({ + backgroundColor: theme.tokens.alias.Background.Neutral, + marginTop: theme.spacing(1), + padding: `${theme.spacing(1)} ${theme.spacing(2)}`, +}); + +export const sharedTemplateRules = ( + <> + Rules + ({ marginTop: theme.spacing(1) })}> + Allow Inbound SSH + + + + Protocol: TCP + + + Ports: 22 + {' '} + + Sources: All IPv4, IPv6 + + + ({ marginTop: theme.spacing(2) })}> + Allow Inbound ICMP + + + + Protocol: ICMP + + + Sources: All IPv4, IPv6 + + + +); + +export const sharedTemplatePolicies = ( + <> + ({ + ...templateRuleStyling(theme), + })} + > + Default Inbound Policy: DROP + + ({ + ...templateRuleStyling(theme), + })} + > + + Default Outbound Policy: ACCEPT + + + +); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/TemplateFirewallFields.test.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/TemplateFirewallFields.test.tsx new file mode 100644 index 00000000000..27d4a932776 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallLanding/TemplateFirewallFields.test.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { TemplateFirewallFields } from './TemplateFirewallFields'; + +const props = { + userCannotAddFirewall: false, +}; + +const formOptions = { + defaultValues: { + createFirewallFrom: 'template', + devices: { + linodes: [], + nodebalancers: [], + }, + label: '', + rules: { + inbound_policy: 'DROP', + outbound_policy: 'ACCEPT', + }, + templateSlug: undefined, + }, +}; + +describe('CustomFirewallFields', () => { + it('renders the custom firewall fields', () => { + const { getByText, queryByTestId } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: formOptions, + }); + + expect( + getByText( + 'Firewall templates enable you to quickly create firewalls with reasonable firewall rules for Public and VPC interfaces that can be edited.' + ) + ).toBeVisible(); + expect(getByText('Firewall Template')).toBeVisible(); + + expect(queryByTestId('vpc-template-info')).not.toBeInTheDocument(); + expect(queryByTestId('public-template-info')).not.toBeInTheDocument(); + }); + + it('renders information for public templates if public is selected', () => { + const { queryByTestId } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + ...formOptions.defaultValues, + templateSlug: 'public', + }, + }, + }); + + expect(queryByTestId('public-template-info')).toBeVisible(); + expect(queryByTestId('vpc-template-info')).not.toBeInTheDocument(); + }); + + it('renders information for vpc templates if vpc is selected', () => { + const { queryByTestId } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: { + defaultValues: { + ...formOptions.defaultValues, + templateSlug: 'vpc', + }, + }, + }); + + expect(queryByTestId('vpc-template-info')).toBeVisible(); + expect(queryByTestId('public-template-info')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/TemplateFirewallFields.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/TemplateFirewallFields.tsx new file mode 100644 index 00000000000..e4ad2f02c44 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallLanding/TemplateFirewallFields.tsx @@ -0,0 +1,88 @@ +import { useFirewallTemplatesQuery } from '@linode/queries'; +import { Select, Typography } from '@linode/ui'; +import * as React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { useIsAkamaiAccount } from 'src/hooks/useIsAkamaiAccount'; + +import { PublicTemplateRules } from './PublicTemplateRules'; +import { VPCTemplateRules } from './VPCTemplateRules'; + +import type { CreateFirewallFormValues } from './formUtilities'; + +interface TemplateFirewallProps { + userCannotAddFirewall: boolean; +} + +const descriptionMap = { + 'akamai-non-prod': null, + public: , + vpc: , +}; + +const templateLabelMap = { + 'akamai-non-prod': 'Akamai Internal Firewall Template', + public: 'Public Firewall Template', + vpc: 'VPC Firewall Template', +}; + +export const TemplateFirewallFields = (props: TemplateFirewallProps) => { + const { userCannotAddFirewall } = props; + const { control, watch } = useFormContext(); + + const selectedTemplate = watch('templateSlug'); + + const isAkamaiAccount = useIsAkamaiAccount(); + + const { data: templates } = useFirewallTemplatesQuery(); + + const firewallTemplateOptions = + templates + ?.filter( + // if account is internal, return all slugs + // otherwise only return non internal Akamai account slugs + // (this endpoint shouldn't return internal templates for + // non-internal accounts, but keeping as an extra failsafe) + (template) => isAkamaiAccount || template.slug !== 'akamai-non-prod' + ) + .map((template) => { + return { + label: templateLabelMap[template.slug], + value: template.slug, + }; + }) ?? []; + + return ( + <> + {!selectedTemplate && ( + + Firewall templates enable you to quickly create firewalls with + reasonable firewall rules for Public and VPC interfaces that can be + edited. + + )} + ( + options.value === selectedConfigId) ?? + null + } + label="Configuration Profile" + onChange={(_, item) => setSelectedConfigId(item.value)} + options={configOptions} + placeholder="Select Configuration Profile" + /> + + + + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/ErrorDialogContent.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/ErrorDialogContent.test.tsx new file mode 100644 index 00000000000..350280635f0 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/ErrorDialogContent.test.tsx @@ -0,0 +1,62 @@ +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ERROR_DRY_RUN_COPY } from '../constants'; +import { ErrorDialogContent } from './ErrorDialogContent'; + +import type { + ErrorDialogState, + UpgradeInterfacesDialogContentProps, +} from '../types'; + +const errorMessage1 = 'example error 1'; + +const props = { + linodeId: 1, + onClose: vi.fn(), + open: true, + setDialogState: vi.fn(), + state: { + dialogTitle: 'Error Dialog Example', + errors: [{ reason: errorMessage1 }, { reason: 'example error 2' }], + isDryRun: true, + step: 'error', + }, +} as UpgradeInterfacesDialogContentProps; + +describe('ErrorDialogContent', () => { + it('can render the error content for a dry run', () => { + const { getByText } = renderWithTheme(); + + getByText(ERROR_DRY_RUN_COPY); + getByText(errorMessage1); + getByText('example error 2'); + getByText('Close'); + }); + + it('can render the error content for an actual upgrade', () => { + const { getByText, queryByText } = renderWithTheme( + + ); + + expect(queryByText(ERROR_DRY_RUN_COPY)).not.toBeInTheDocument(); + getByText(errorMessage1); + getByText('Close'); + }); + + it('can close the dialog', async () => { + const { getByText } = renderWithTheme(); + + const cancelButton = getByText('Close'); + await userEvent.click(cancelButton); + expect(props.onClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/ErrorDialogContent.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/ErrorDialogContent.tsx new file mode 100644 index 00000000000..b77ed167d4e --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/ErrorDialogContent.tsx @@ -0,0 +1,52 @@ +import { + Box, + Button, + List, + ListItem, + Notice, + Stack, + Typography, +} from '@linode/ui'; +import React from 'react'; + +import { ERROR_DRY_RUN_COPY } from '../constants'; + +import type { + ErrorDialogState, + UpgradeInterfacesDialogContentProps, +} from '../types'; + +export const ErrorDialogContent = ( + props: UpgradeInterfacesDialogContentProps +) => { + const { onClose, state } = props; + const { errors, isDryRun } = state; + + return ( + + + + {isDryRun ? ERROR_DRY_RUN_COPY : 'Unable to upgrade interfaces.'} + + + {errors.length > 0 && ( + + {errors.map((error) => ( + + {error.reason} + + ))} + + )} + + + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/PromptDialogContent.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/PromptDialogContent.test.tsx new file mode 100644 index 00000000000..b9e34727e38 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/PromptDialogContent.test.tsx @@ -0,0 +1,76 @@ +import { waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { linodeConfigFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { HttpResponse, http, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { PromptDialogContent } from './PromptDialogContent'; + +import type { + PromptDialogState, + UpgradeInterfacesDialogContentProps, +} from '../types'; + +const loadingTestId = 'circle-progress'; + +const props = { + linodeId: 1, + onClose: vi.fn(), + open: true, + setDialogState: vi.fn(), + state: { + dialogTitle: 'Upgrade Interfaces', + step: 'prompt', + }, +} as UpgradeInterfacesDialogContentProps; + +describe('PromptDialogContent', () => { + it('can render the prompt content', async () => { + server.use( + http.get('*/linode/instances/:id/configs', () => { + return HttpResponse.json( + makeResourcePage(linodeConfigFactory.buildList(2)) + ); + }) + ); + + const { getByTestId, getByText } = renderWithTheme( + + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + getByText('Upgrade Dry Run'); + getByText('Upgrade Interfaces'); + getByText('Cancel'); + }); + + it('can close the dialog', async () => { + server.use( + http.get('*/linode/instances/:id/configs', () => { + return HttpResponse.json( + makeResourcePage(linodeConfigFactory.buildList(2)) + ); + }) + ); + + const { getByTestId, getByText } = renderWithTheme( + + ); + + // Loading state should render + expect(getByTestId(loadingTestId)).toBeInTheDocument(); + + await waitForElementToBeRemoved(getByTestId(loadingTestId)); + + const cancelButton = getByText('Cancel'); + await userEvent.click(cancelButton); + expect(props.onClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/PromptDialogContent.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/PromptDialogContent.tsx new file mode 100644 index 00000000000..e20f4103fbc --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/PromptDialogContent.tsx @@ -0,0 +1,131 @@ +import { useAllLinodeConfigsQuery } from '@linode/queries'; +import { + Box, + Button, + CircleProgress, + List, + ListItem, + Stack, + Typography, +} from '@linode/ui'; +import React from 'react'; + +import { useUpgradeToLinodeInterfaces } from '../useUpgradeToLinodeInterfaces'; + +import type { + PromptDialogState, + UpgradeInterfacesDialogContentProps, +} from '../types'; + +export const PromptDialogContent = ( + props: UpgradeInterfacesDialogContentProps +) => { + const { linodeId, onClose, open, setDialogState } = props; + + const [isDryRun, setIsDryRun] = React.useState(true); + + const { + data: configs, + isLoading: isLoadingConfigs, + } = useAllLinodeConfigsQuery(linodeId, open); + + const { isPending, upgradeToLinodeInterfaces } = useUpgradeToLinodeInterfaces( + { + linodeId, + selectedConfig: configs?.[0], + setDialogState, + } + ); + + if (isLoadingConfigs) { + return ; + } + + const isPendingDryRun = isPending && isDryRun; + const isPendingUpgrade = isPending && !isDryRun; + + const upgradeDryRun = + configs && configs.length > 1 + ? () => + setDialogState({ + configs, + dialogTitle: 'Upgrade Dry Run', + isDryRun: true, + step: 'configSelect', + }) + : () => { + setIsDryRun(true); + upgradeToLinodeInterfaces(true); + }; + const upgradeInterfaces = + configs && configs?.length > 1 + ? () => + setDialogState({ + configs, + dialogTitle: 'Upgrade Interfaces', + isDryRun: false, + step: 'configSelect', + }) + : () => { + setIsDryRun(false); + upgradeToLinodeInterfaces(false); + }; + + return ( + + + Upgrading allows interface connections to be directly associated with + the Linode and not the Linode's configuration profile. + + + It is recommended that you perform a dry run before upgrading to verify + and resolve potential conflicts during the upgrade. + + + Upgrading will have the following impact: + + + + Any firewall attached to the Linode will be removed and a default + firewall will be attached to the new interface automatically. + + + If a firewall is not currently assigned, one will be added during the + upgrade to improve security. + {' '} + + All networking configurations will be deleted from the configuration + profile and re-assigned to the neew interfaces in the Linode Network + tab. + + + + + + + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/SuccessDialogContent.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/SuccessDialogContent.test.tsx new file mode 100644 index 00000000000..7fa7b26dd0a --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/SuccessDialogContent.test.tsx @@ -0,0 +1,66 @@ +import { linodeInterfaceFactoryVPC } from '@linode/utilities'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { SUCCESS_DRY_RUN_COPY, SUCCESS_UPGRADE_COPY } from '../constants'; +import { SuccessDialogContent } from './SuccessDialogContent'; + +import type { + SuccessDialogState, + UpgradeInterfacesDialogContentProps, +} from '../types'; + +const props = { + linodeId: 1, + onClose: vi.fn(), + open: true, + setDialogState: vi.fn(), + state: { + dialogTitle: 'Upgrade Dry Run', + isDryRun: true, + linodeInterfaces: [linodeInterfaceFactoryVPC.build()], + step: 'success', + }, +} as UpgradeInterfacesDialogContentProps; + +describe('SuccessDialogContent', () => { + it('can render the success content for a dry run', () => { + const { getByText, queryByText } = renderWithTheme( + + ); + + getByText('Upgrade Interfaces'); + getByText(SUCCESS_DRY_RUN_COPY); + getByText('Cancel'); + + expect(queryByText('Upgrade Summary')).not.toBeInTheDocument(); + }); + + it('can render the success content for the actual upgrade', () => { + const { getByText } = renderWithTheme( + + ); + + getByText(SUCCESS_UPGRADE_COPY); + getByText('Close'); + getByText('Upgrade Summary'); + getByText('Interface Meta Info: Interface #1'); + getByText('VPC Interface Details'); + }); + + it('can close the dialog', async () => { + const { getByText } = renderWithTheme(); + + const cancelButton = getByText('Cancel'); + await userEvent.click(cancelButton); + expect(props.onClose).toHaveBeenCalled(); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/SuccessDialogContent.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/SuccessDialogContent.tsx new file mode 100644 index 00000000000..2e1360047ff --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/DialogContents/SuccessDialogContent.tsx @@ -0,0 +1,178 @@ +import { Box, Button, Notice, Stack, Typography } from '@linode/ui'; +import React from 'react'; + +import { SUCCESS_DRY_RUN_COPY, SUCCESS_UPGRADE_COPY } from '../constants'; +import { useUpgradeToLinodeInterfaces } from '../useUpgradeToLinodeInterfaces'; + +import type { + SuccessDialogState, + UpgradeInterfacesDialogContentProps, +} from '../types'; +import type { LinodeInterface, VPCInterfaceData } from '@linode/api-v4'; + +export const SuccessDialogContent = ( + props: UpgradeInterfacesDialogContentProps +) => { + const { linodeId, onClose, setDialogState, state } = props; + const { isDryRun, linodeInterfaces, selectedConfig } = state; + + const { isPending, upgradeToLinodeInterfaces } = useUpgradeToLinodeInterfaces( + { + linodeId, + selectedConfig, + setDialogState, + } + ); + + return ( + + + + {isDryRun ? SUCCESS_DRY_RUN_COPY : SUCCESS_UPGRADE_COPY} + + + {!isDryRun && linodeInterfaces.length > 0 && ( + ({ + backgroundColor: theme.tokens.alias.Background.Neutral, + marginTop: theme.spacing(1), + padding: theme.spacing(2), + })} + > + ({ + marginTop: theme.spacing(1), + })} + variant="h3" + > + Upgrade Summary + + {linodeInterfaces.map((linodeInterface) => ( + + ))} + + )} + + + {isDryRun && ( + + )} + + + ); +}; + +const LinodeInterfaceInfo = (props: LinodeInterface) => { + const { + created, + id, + mac_address, + public: publicInterface, + updated, + version, + vlan, + vpc, + } = props; + + return ( + <> + ({ + marginBottom: theme.spacing(2), + marginTop: theme.spacing(2), + })} + > + Interface Meta Info: Interface #{id} + + ID: {id} + MAC Address: {mac_address} + Created: {created} + Updated: {updated} + Version: {version} + {publicInterface && ( + ({ + marginBottom: theme.spacing(2), + marginTop: theme.spacing(2), + })} + > + Public Interface successfully upgraded + + )} + {vpc && } + {vlan && } + + ); +}; + +type VPCInterfaceInfo = VPCInterfaceData & + Pick; + +const VPCInterfaceInfo = (props: VPCInterfaceInfo) => { + const { default_route, ipv4, subnet_id, vpc_id } = props; + const { addresses, ranges } = ipv4; + + const primaryAddress = addresses.find((address) => address.primary === true); + + return ( + <> + ({ + marginBottom: theme.spacing(2), + marginTop: theme.spacing(2), + })} + > + VPC Interface Details + + {default_route && ( + + Default Route:{' '} + {default_route.ipv4 ? 'IPv4' : default_route.ipv6 ? 'IPv6' : 'None'} + + )} + VPC ID: {vpc_id} + Subnet ID: {subnet_id} + + Addresses: {addresses.map((address) => address.address).join(', ')} + + {primaryAddress && ( + Primary Address: {primaryAddress.address} + )} + {ranges.length > 0 && ( + Routed Ranges: {ranges.join(', ')} + )} + + ); +}; + +const VlanInterfaceInfo = (props: Pick) => { + const { vlan } = props; + + const { ipam_address, vlan_label } = vlan!; + + return ( + <> + ({ + marginBottom: theme.spacing(2), + marginTop: theme.spacing(2), + })} + > + VLAN Interface Details + + Label: {vlan_label} + IPAM Address: {ipam_address} + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/UpgradeInterfacesDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/UpgradeInterfacesDialog.tsx new file mode 100644 index 00000000000..876cf7623a7 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/UpgradeInterfacesDialog.tsx @@ -0,0 +1,67 @@ +import { Dialog } from '@linode/ui'; +import React from 'react'; + +import { ConfigSelectDialogContent } from './DialogContents/ConfigSelectDialogContent'; +import { ErrorDialogContent } from './DialogContents/ErrorDialogContent'; +import { PromptDialogContent } from './DialogContents/PromptDialogContent'; +import { SuccessDialogContent } from './DialogContents/SuccessDialogContent'; + +import type { UpgradeInterfacesDialogState } from './types'; + +interface UpgradeInterfacesProps { + linodeId: number; + onClose: () => void; + open: boolean; +} + +const initialState: UpgradeInterfacesDialogState = { + dialogTitle: 'Upgrade Interfaces', + step: 'prompt', +}; + +export const UpgradeInterfacesDialog = (props: UpgradeInterfacesProps) => { + const { linodeId, onClose, open } = props; + + const [ + dialogState, + setDialogState, + ] = React.useState({ ...initialState }); + + const closeAndResetDialog = () => { + onClose(); + setDialogState({ + ...initialState, + }); + }; + + const dialogProps = { + linodeId, + onClose: closeAndResetDialog, + open, + setDialogState, + }; + + return ( + + {dialogState.step === 'prompt' && ( + + )} + {dialogState.step === 'configSelect' && ( + + )} + {dialogState.step === 'error' && ( + + )} + {dialogState.step === 'success' && ( + + )} + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/constants.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/constants.ts new file mode 100644 index 00000000000..0b8cedb2f78 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/constants.ts @@ -0,0 +1,15 @@ +export const CONFIG_SELECT_DRY_RUN_COPY = + 'This Linode has multiple configuration profiles. Please select a configuration profile to proceed. The upgrade dry run will identify any issues that may occur before upgrading.'; + +export const CONFIG_SELECT_ACTUAL_UPGRADE_COPY = + 'This Linode has multiple configuration profiles. Please select a configuration profile to upgrade. The upgrade will reassign the network interfaces from the configuration profile to the Linode.'; + +export const UPGRADE_INTERFACES_WARNING = + 'Once the network interfaces have been reassigned to the Linode, it will no longer be possible to use legacy configuration profiles.'; + +export const SUCCESS_DRY_RUN_COPY = + 'The configuration dry run was successful and no issues were found. You can proceed with upgrading interfaces.'; +export const SUCCESS_UPGRADE_COPY = 'The configuration upgrade was successful.'; + +export const ERROR_DRY_RUN_COPY = + 'The configuration dry run found the following issues. Please correct the issues and perform another dry run before upgrading the interface configuration.'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/types.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/types.ts new file mode 100644 index 00000000000..fd03451ed27 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/types.ts @@ -0,0 +1,46 @@ +import type { APIError, Config, LinodeInterface } from '@linode/api-v4'; + +export interface UpgradeInterfacesDialogContentProps< + State extends UpgradeInterfacesDialogState +> { + linodeId: number; + onClose: () => void; + open: boolean; + setDialogState: (state: UpgradeInterfacesDialogState) => void; + state: State; +} + +export type UpgradeInterfacesDialogState = + | ConfigSelectDialogState + | ErrorDialogState + | PromptDialogState + | SuccessDialogState; + +export interface BaseDialogState { + dialogTitle: string; + step: 'configSelect' | 'error' | 'prompt' | 'success'; +} + +export interface PromptDialogState extends BaseDialogState { + step: 'prompt'; +} + +export interface ConfigSelectDialogState extends BaseDialogState { + configs: Config[]; + isDryRun: boolean; + step: 'configSelect'; +} + +export interface SuccessDialogState extends BaseDialogState { + isDryRun: boolean; + linodeInterfaces: LinodeInterface[]; + selectedConfig?: Config; + step: 'success'; +} + +export interface ErrorDialogState extends BaseDialogState { + errors: APIError[]; + isDryRun: boolean; + selectedConfig?: Config; + step: 'error'; +} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/useUpgradeToLinodeInterfaces.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/useUpgradeToLinodeInterfaces.ts new file mode 100644 index 00000000000..cf7996e206d --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/UpgradeInterfaces/useUpgradeToLinodeInterfaces.ts @@ -0,0 +1,49 @@ +import { useUpgradeToLinodeInterfacesMutation } from '@linode/queries'; + +import type { UpgradeInterfacesDialogState } from './types'; +import type { Config } from '@linode/api-v4'; + +export const useUpgradeToLinodeInterfaces = (options: { + linodeId: number; + selectedConfig?: Config; + setDialogState: (state: UpgradeInterfacesDialogState) => void; +}) => { + const { linodeId, selectedConfig, setDialogState } = options; + + const { + isPending, + mutateAsync: upgradeInterfaces, + } = useUpgradeToLinodeInterfacesMutation(linodeId); + + return { + isPending, + upgradeToLinodeInterfaces: async (isDryRun: boolean) => { + const dialogTitle = `${isDryRun ? 'Dry Run' : 'Upgrade'}: ${ + selectedConfig?.label ?? '' + }`; + try { + const returnedData = await upgradeInterfaces({ + dry_run: isDryRun, + ...(selectedConfig ? { config_id: selectedConfig.id } : {}), + }); + + // When finished upgrading, move on to the success state + setDialogState({ + dialogTitle, + isDryRun, + linodeInterfaces: returnedData.interfaces, + selectedConfig, + step: 'success', + }); + } catch (errors) { + setDialogState({ + dialogTitle, + errors, + isDryRun, + selectedConfig, + step: 'error', + }); + } + }, + }; +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.ts index 1f5f7c47352..b5d4ed998ee 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.ts +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeConfigs/utilities.test.ts @@ -1,4 +1,4 @@ -import { LinodeConfigInterfaceFactory } from 'src/factories'; +import { linodeConfigInterfaceFactory } from '@linode/utilities'; import { getPrimaryInterfaceIndex } from './utilities'; @@ -9,9 +9,9 @@ describe('getPrimaryInterfaceIndex', () => { it('returns the primary interface when one is designated as primary', () => { const interfaces = [ - LinodeConfigInterfaceFactory.build({ primary: false }), - LinodeConfigInterfaceFactory.build({ primary: true }), - LinodeConfigInterfaceFactory.build({ primary: false }), + linodeConfigInterfaceFactory.build({ primary: false }), + linodeConfigInterfaceFactory.build({ primary: true }), + linodeConfigInterfaceFactory.build({ primary: false }), ]; expect(getPrimaryInterfaceIndex(interfaces)).toBe(1); @@ -19,8 +19,8 @@ describe('getPrimaryInterfaceIndex', () => { it('returns the index of the first non-VLAN interface if there is no interface designated as primary', () => { const interfaces = [ - LinodeConfigInterfaceFactory.build({ primary: false, purpose: 'vlan' }), - LinodeConfigInterfaceFactory.build({ primary: false, purpose: 'public' }), + linodeConfigInterfaceFactory.build({ primary: false, purpose: 'vlan' }), + linodeConfigInterfaceFactory.build({ primary: false, purpose: 'public' }), ]; expect(getPrimaryInterfaceIndex(interfaces)).toBe(1); @@ -28,7 +28,7 @@ describe('getPrimaryInterfaceIndex', () => { it('returns null when there is no primary interface', () => { const interfaces = [ - LinodeConfigInterfaceFactory.build({ primary: false, purpose: 'vlan' }), + linodeConfigInterfaceFactory.build({ primary: false, purpose: 'vlan' }), ]; expect(getPrimaryInterfaceIndex(interfaces)).toBe(null); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx index a7c02788deb..e4a7448c775 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/AddIPDrawer.tsx @@ -1,24 +1,26 @@ -import { FormControlLabel, Typography } from '@linode/ui'; import { + useAllocateIPMutation, + useCreateIPv6RangeMutation, + useLinodeIPsQuery, +} from '@linode/queries'; +import { + ActionsPanel, Box, Divider, + Drawer, + FormControlLabel, Notice, Radio, RadioGroup, Stack, Tooltip, + Typography, } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; import { Link } from 'src/components/Link'; -import { - useAllocateIPMutation, - useLinodeIPsQuery, -} from 'src/queries/linodes/networking'; -import { useCreateIPv6RangeMutation } from 'src/queries/networking/networking'; +import { NotFound } from 'src/components/NotFound'; import { ExplainerCopy } from './ExplainerCopy'; @@ -158,7 +160,12 @@ export const AddIPDrawer = (props: Props) => { : null; return ( - + IPv4 {Boolean(ipv4Error) && ( diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/DeleteIPDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/DeleteIPDialog.tsx index 0f6b586824d..df8a8edd576 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/DeleteIPDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/DeleteIPDialog.tsx @@ -1,10 +1,9 @@ -import { Typography } from '@linode/ui'; +import { ActionsPanel, Typography } from '@linode/ui'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { useLinodeIPDeleteMutation } from 'src/queries/linodes/networking'; +import { useLinodeIPDeleteMutation } from '@linode/queries'; interface Props { address: string; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/DeleteRangeDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/DeleteRangeDialog.tsx index 6b9f51a1084..51a7a0b0324 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/DeleteRangeDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/DeleteRangeDialog.tsx @@ -1,10 +1,9 @@ -import { Typography } from '@linode/ui'; +import { ActionsPanel, Typography } from '@linode/ui'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; -import { useLinodeRemoveRangeMutation } from 'src/queries/linodes/networking'; +import { useLinodeRemoveRangeMutation } from '@linode/queries'; import type { IPRange } from '@linode/api-v4'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditIPRDNSDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditIPRDNSDrawer.tsx index 597112500e9..3fc60fcbae4 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditIPRDNSDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditIPRDNSDrawer.tsx @@ -1,11 +1,10 @@ -import { Notice, TextField } from '@linode/ui'; +import { useLinodeIPMutation } from '@linode/queries'; +import { ActionsPanel, Drawer, Notice, TextField } from '@linode/ui'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; -import { useLinodeIPMutation } from 'src/queries/linodes/networking'; +import { NotFound } from 'src/components/NotFound'; import { getErrorMap } from 'src/utilities/errorUtils'; import type { IPAddress } from '@linode/api-v4/lib/networking'; @@ -53,7 +52,12 @@ export const EditIPRDNSDrawer = (props: Props) => { const errorMap = getErrorMap(['rdns'], error); return ( - +
    {Boolean(errorMap.none) && ( {errorMap.none} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.tsx index b966c715d65..de4bb4cc593 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/EditRangeRDNSDrawer.tsx @@ -1,14 +1,21 @@ -import { Notice, TextField, Typography } from '@linode/ui'; +import { + useAllIPsQuery, + useLinodeIPMutation, + useLinodeQuery, +} from '@linode/queries'; +import { + ActionsPanel, + Drawer, + Notice, + TextField, + Typography, +} from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import { useFormik } from 'formik'; import { useSnackbar } from 'notistack'; import * as React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; -import { useLinodeQuery } from 'src/queries/linodes/linodes'; -import { useLinodeIPMutation } from 'src/queries/linodes/networking'; -import { useAllIPsQuery } from 'src/queries/networking/networking'; +import { NotFound } from 'src/components/NotFound'; import { getErrorMap } from 'src/utilities/errorUtils'; import { listIPv6InRange } from './LinodeIPAddressRow'; @@ -77,7 +84,12 @@ export const EditRangeRDNSDrawer = (props: Props) => { const errorMap = getErrorMap(['rdns'], error); return ( - + {Boolean(errorMap.none) && ( diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.test.tsx index 5281267a1f9..ce7d9a97916 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.test.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.test.tsx @@ -10,8 +10,8 @@ const queryMocks = vi.hoisted(() => ({ useLinodeQuery: vi.fn().mockReturnValue({ data: undefined }), })); -vi.mock('src/queries/linodes/linodes', async () => { - const actual = await vi.importActual('src/queries/linodes/linodes'); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); return { ...actual, useLinodeQuery: queryMocks.useLinodeQuery, diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.tsx index 7328db2ddbe..4cb88752185 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/ExplainerCopy.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { SupportLink } from 'src/components/SupportLink'; -import { useLinodeQuery } from 'src/queries/linodes/linodes'; +import { useLinodeQuery } from '@linode/queries'; import type { IPType } from './AddIPDrawer'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx index cf376dc7719..b9f32081610 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/IPSharing.tsx @@ -1,4 +1,12 @@ import { + useAllDetailedIPv6RangesQuery, + useAllLinodesQuery, + useLinodeIPsQuery, + useLinodeQuery, + useLinodeShareIPMutation, +} from '@linode/queries'; +import { + ActionsPanel, Button, CircleProgress, Dialog, @@ -8,25 +16,13 @@ import { TextField, Typography, } from '@linode/ui'; +import { API_MAX_PAGE_SIZE, areArraysEqual } from '@linode/utilities'; +import Grid from '@mui/material/Grid2'; import { useTheme } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; -import { remove, uniq, update } from 'ramda'; import * as React from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Link } from 'src/components/Link'; -import { API_MAX_PAGE_SIZE } from 'src/constants'; import { useFlags } from 'src/hooks/useFlags'; -import { - useAllLinodesQuery, - useLinodeQuery, -} from 'src/queries/linodes/linodes'; -import { - useLinodeIPsQuery, - useLinodeShareIPMutation, -} from 'src/queries/linodes/networking'; -import { useAllDetailedIPv6RangesQuery } from 'src/queries/networking/networking'; -import { areArraysEqual } from 'src/utilities/areArraysEqual'; import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; import type { Linode } from '@linode/api-v4/lib/linodes'; @@ -169,14 +165,12 @@ const IPSharingPanel = (props: Props) => { setIpsToShare((currentIps) => { return ipIdx >= currentIps.length ? [...currentIps, e.value] - : update(ipIdx, e.value, currentIps); + : currentIps.map((val, idx) => (idx === ipIdx ? e.value : val)); }); }; const onIPDelete = (ipIdx: number) => { - setIpsToShare((currentIps) => { - return remove(ipIdx, 1, currentIps); - }); + setIpsToShare((currentIps) => currentIps.filter((_, idx) => idx !== ipIdx)); }; const handleClose = () => { @@ -193,32 +187,35 @@ const IPSharingPanel = (props: Props) => { const onSubmit = () => { const groupedUnsharedRanges: Record = {}; - const finalIPs: string[] = uniq( - ipsToShare.reduce((previousValue, currentValue) => { - if (currentValue === undefined || currentValue === null) { - return previousValue; - } - const strippedIP: string = currentValue.split('/')[0]; - - // Filter out v4s and shared v6 ranges as only v6s and unshared ips will be added - const isStaticv6 = ipToLinodeID?.[currentValue]?.length === 1; - // For any IP in finalIPs that isn't shared (length of linode_ids === 1) - // make note in groupedUnsharedRanges so that we can first share that IP to - // the Linode it is statically routed to, then to the current Linode - if (isStaticv6) { - const linodeId = ipToLinodeID[currentValue][0]; - if (groupedUnsharedRanges.hasOwnProperty(linodeId)) { - groupedUnsharedRanges[linodeId] = [ - ...groupedUnsharedRanges[linodeId], - strippedIP, - ]; - } else { - groupedUnsharedRanges[linodeId] = [strippedIP]; - } + const strippedIPs = ipsToShare.reduce((previousValue, currentValue) => { + if (currentValue === undefined || currentValue === null) { + return previousValue; + } + const strippedIP: string = currentValue.split('/')[0]; + + // Filter out v4s and shared v6 ranges as only v6s and unshared ips will be added + const isStaticv6 = ipToLinodeID?.[currentValue]?.length === 1; + // For any IP in finalIPs that isn't shared (length of linode_ids === 1) + // make note in groupedUnsharedRanges so that we can first share that IP to + // the Linode it is statically routed to, then to the current Linode + if (isStaticv6) { + const linodeId = ipToLinodeID[currentValue][0]; + if (groupedUnsharedRanges.hasOwnProperty(linodeId)) { + groupedUnsharedRanges[linodeId] = [ + ...groupedUnsharedRanges[linodeId], + strippedIP, + ]; + } else { + groupedUnsharedRanges[linodeId] = [strippedIP]; } + } + + return [...previousValue, strippedIP]; + }, []); - return [...previousValue, strippedIP]; - }, []) + const finalIPs = strippedIPs.reduce( + (acc: string[], ip) => (acc.includes(ip) ? acc : [...acc, ip]), + [] ); // use local variable and state because useState won't update state right away @@ -300,17 +297,23 @@ const IPSharingPanel = (props: Props) => { <> {generalError && ( - + )} {successMessage && ( - + )} - + {flags.ipv6Sharing ? ( @@ -333,7 +336,7 @@ const IPSharingPanel = (props: Props) => { sharing. - + = React.memo((props) => { const { ip } = props; return ( - + - + = React.memo((props) => { return ( - + - + ) => setFieldValue('service_type', item.value) @@ -206,7 +217,12 @@ const MonitorDrawer = (props: MonitorDrawerProps) => { value={getValueFromItem(values.service_type, typeOptions)} /> - + { const { error, isFetching, issues, monitorLabel, onClose, open } = props; return ( ({ diff --git a/packages/manager/src/features/Managed/Monitors/IssueDay.tsx b/packages/manager/src/features/Managed/Monitors/IssueDay.tsx index b0cb33e6f06..2a2d7af15e5 100644 --- a/packages/manager/src/features/Managed/Monitors/IssueDay.tsx +++ b/packages/manager/src/features/Managed/Monitors/IssueDay.tsx @@ -1,5 +1,5 @@ import { Tooltip } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import Bad from 'src/assets/icons/monitor-failed.svg'; diff --git a/packages/manager/src/features/Managed/Monitors/MonitorActionMenu.tsx b/packages/manager/src/features/Managed/Monitors/MonitorActionMenu.tsx index 6990e6475ac..8dd5a1708a3 100644 --- a/packages/manager/src/features/Managed/Monitors/MonitorActionMenu.tsx +++ b/packages/manager/src/features/Managed/Monitors/MonitorActionMenu.tsx @@ -1,3 +1,4 @@ +import { splitAt } from '@linode/utilities'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import { useSnackbar } from 'notistack'; @@ -10,7 +11,6 @@ import { useEnableMonitorMutation, } from 'src/queries/managed/managed'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { splitAt } from 'src/utilities/splitAt'; import type { MonitorStatus } from '@linode/api-v4/lib/managed'; import type { APIError } from '@linode/api-v4/lib/types'; diff --git a/packages/manager/src/features/Managed/Monitors/MonitorRow.styles.tsx b/packages/manager/src/features/Managed/Monitors/MonitorRow.styles.tsx index d454366e1ea..fd372426b08 100644 --- a/packages/manager/src/features/Managed/Monitors/MonitorRow.styles.tsx +++ b/packages/manager/src/features/Managed/Monitors/MonitorRow.styles.tsx @@ -1,8 +1,8 @@ import { Typography } from '@linode/ui'; +import Grid from '@mui/material/Grid2'; import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; -import { Link } from 'react-router-dom'; +import { Link } from 'src/components/Link'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; diff --git a/packages/manager/src/features/Managed/Monitors/MonitorRow.tsx b/packages/manager/src/features/Managed/Monitors/MonitorRow.tsx index 0192fcd4ac4..16a3adaf4cb 100644 --- a/packages/manager/src/features/Managed/Monitors/MonitorRow.tsx +++ b/packages/manager/src/features/Managed/Monitors/MonitorRow.tsx @@ -1,5 +1,5 @@ import { Tooltip, Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import TicketIcon from 'src/assets/icons/ticket.svg'; @@ -50,7 +50,14 @@ export const MonitorRow = (props: MonitorRowProps) => { key={monitor.id} > - + @@ -58,7 +65,14 @@ export const MonitorRow = (props: MonitorRowProps) => { - + {statusTextMap[monitor.status]} diff --git a/packages/manager/src/features/Managed/Monitors/MonitorTable.styles.tsx b/packages/manager/src/features/Managed/Monitors/MonitorTable.styles.tsx index 81297166d77..55c394bb06c 100644 --- a/packages/manager/src/features/Managed/Monitors/MonitorTable.styles.tsx +++ b/packages/manager/src/features/Managed/Monitors/MonitorTable.styles.tsx @@ -1,5 +1,5 @@ import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; export const StyledGrid = styled(Grid, { label: 'StyledGrid', diff --git a/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx b/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx index d3c592e4bd4..7a4d0ed97db 100644 --- a/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx +++ b/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx @@ -1,5 +1,6 @@ import { Button } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import { useDialog } from '@linode/utilities'; +import Grid from '@mui/material/Grid2'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -14,7 +15,6 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell'; -import { useDialog } from 'src/hooks/useDialog'; import { useAllManagedContactsQuery, useAllManagedCredentialsQuery, @@ -174,9 +174,20 @@ export const MonitorTable = () => { return ( <> - + - + + ); }; diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx index eb9badab842..a2a94aaf12f 100644 --- a/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketDetail/CreateFolderDrawer.tsx @@ -1,9 +1,8 @@ -import { TextField } from '@linode/ui'; +import { ActionsPanel, Drawer, TextField } from '@linode/ui'; import { useFormik } from 'formik'; import React, { useEffect } from 'react'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; +import { NotFound } from 'src/components/NotFound'; import { useCreateObjectUrlMutation } from 'src/queries/object-storage/queries'; interface Props { @@ -77,7 +76,12 @@ export const CreateFolderDrawer = (props: Props) => { }, [open]); return ( - +