diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2d1bd208bd..018fb457ed6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,106 +12,86 @@ jobs: lint: strategy: matrix: - package: ["linode-manager", "@linode/api-v4", "@linode/validation", "@linode/ui"] + package: + [ + "linode-manager", + "@linode/api-v4", + "@linode/validation", + "@linode/ui", + "@linode/utilities", + ] 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 +102,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 +123,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 +143,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 +159,99 @@ 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" - - uses: actions/cache@v4 + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter @linode/ui test + + test-utilities: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - run: yarn --frozen-lockfile - - run: yarn workspace @linode/ui run test + 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/utilities test 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 - with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - run: yarn --frozen-lockfile - - run: yarn workspace @linode/ui run typecheck + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter @linode/ui typecheck - typecheck-manager: + typecheck-utilities: 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 - with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} - - uses: actions/download-artifact@v4 - with: - name: packages-validation-lib - path: packages/validation/lib - - 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 + cache: "pnpm" + - run: pnpm install --frozen-lockfile + - run: pnpm run --filter @linode/utilities typecheck - build-manager: + typecheck-manager: 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 - with: - path: | - **/node_modules - key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + cache: "pnpm" - uses: actions/download-artifact@v4 with: name: packages-validation-lib @@ -268,43 +260,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 +311,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 +327,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 index 4da3dbe498b..efe2cd70c4b 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -11,28 +11,28 @@ jobs: with: ref: ${{ github.base_ref }} # The base branch of the PR (develop) + - 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: Write Base Coverage to an Artifact run: | @@ -56,28 +56,28 @@ jobs: steps: - 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 Current Branch Coverage - run: yarn coverage:summary + run: pnpm coverage:summary - name: Write PR Number to an Artifact run: | 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/e2e_schedule_and_push.yml b/.github/workflows/e2e_schedule_and_push.yml index e95da872490..6afacc93369 100644 --- a/.github/workflows/e2e_schedule_and_push.yml +++ b/.github/workflows/e2e_schedule_and_push.yml @@ -28,17 +28,13 @@ jobs: - 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 @@ -57,9 +53,9 @@ 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 & + - 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 +63,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 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..6e7a3525d15 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ ## 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 [`@linode/api-v4`](packages/api-v4/), [`@linode/validation`](packages/validation/), [`@linode/ui`](packages/ui/), and [`@linode/utilities`](packages/utilities/) Typescript packages. ## 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/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/package.json b/package.json index fe481632491..3b82c8380ea 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,manager -c blue,yellow,magenta,cyan,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-manager start\"", + "start:all:expose": "concurrently -n api-v4,validation,ui,utilities,manager -c blue,yellow,magenta,cyan,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-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..f2c6a8739c5 100644 --- a/packages/api-v4/CHANGELOG.md +++ b/packages/api-v4/CHANGELOG.md @@ -1,3 +1,20 @@ +## [2025-03-17] - v0.136.0 + +### 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)) +- Update Region `Capabilities` type to temporarily include LA Disk Encryption ([#11783](https://github.com/linode/manager/pull/11783)) + +### Upcoming Features: + +- Update region capability and Public Interface object for Linode Interfaces ([#11621](https://github.com/linode/manager/pull/11621)) +- Add 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)) +- Add `engine_config` to the Database Instance for DBaaS Advanced Configurations ([#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)) + ## [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..7e8c49871d1 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,7 +40,7 @@ "browser": "./lib/index.global.js", "unpkg": "./lib/index.global.js", "dependencies": { - "@linode/validation": "*", + "@linode/validation": "workspace:*", "axios": "~1.7.4", "ipaddr.js": "^2.0.0", "yup": "^1.4.0" @@ -48,10 +48,9 @@ "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,7 +60,6 @@ "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" }, 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..f79104daf03 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,13 @@ 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') + ); diff --git a/packages/api-v4/src/cloudpulse/types.ts b/packages/api-v4/src/cloudpulse/types.ts index 8bbb635e28a..a903db83ed6 100644 --- a/packages/api-v4/src/cloudpulse/types.ts +++ b/packages/api-v4/src/cloudpulse/types.ts @@ -308,14 +308,23 @@ 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'; 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/kubernetes/kubernetes.ts b/packages/api-v4/src/kubernetes/kubernetes.ts index faba1e5ba66..029bd0df976 100644 --- a/packages/api-v4/src/kubernetes/kubernetes.ts +++ b/packages/api-v4/src/kubernetes/kubernetes.ts @@ -1,4 +1,7 @@ -import { createKubeClusterSchema } from '@linode/validation/lib/kubernetes.schema'; +import { + createKubeClusterSchema, + createKubeEnterpriseClusterSchema, +} from '@linode/validation/lib/kubernetes.schema'; import { API_ROOT, BETA_API_ROOT } from '../constants'; import Request, { setData, @@ -94,7 +97,12 @@ export const createKubernetesClusterBeta = (data: CreateKubeClusterPayload) => { return Request( setMethod('POST'), setURL(`${BETA_API_ROOT}/lke/clusters`), - setData(data, createKubeClusterSchema) + setData( + data, + data.tier === 'enterprise' + ? createKubeEnterpriseClusterSchema + : createKubeClusterSchema + ) ); }; 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..f5128bc5bc1 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; @@ -287,17 +287,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; }[]; }; } @@ -646,17 +652,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/nodebalancers.ts b/packages/api-v4/src/nodebalancers/nodebalancers.ts index c3039693740..35f378bdea4 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 * diff --git a/packages/api-v4/src/nodebalancers/types.ts b/packages/api-v4/src/nodebalancers/types.ts index ef1e1b62e4b..f969daa2c4c 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; 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/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..1febffd7198 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,10 @@ module.exports = { 'build', 'storybook-static', '.storybook', - 'e2e/core', + 'e2e/core/placementGroups', + 'e2e/core/stackscripts', + 'e2e/core/volumes', + 'e2e/core/vpc', 'public', '!.eslintrc.js', ], @@ -91,6 +95,7 @@ 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/**/*', @@ -137,11 +142,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,6 +191,7 @@ module.exports = { ], rules: { '@linode/cloud-manager/deprecate-formik': 'warn', + '@linode/cloud-manager/no-createLinode': 'off', '@linode/cloud-manager/no-custom-fontWeight': 'error', '@typescript-eslint/consistent-type-imports': 'warn', '@typescript-eslint/explicit-function-return-type': 'off', 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..45ed4eef732 100644 --- a/packages/manager/CHANGELOG.md +++ b/packages/manager/CHANGELOG.md @@ -4,11 +4,104 @@ 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-17] - v1.138.0 + +### Added: + +- Link to Linode's Firewall in Linode Entity Details ([#11736](https://github.com/linode/manager/pull/11736)) + +### Changed: + +- Update 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)) +- Allow Tags in Volume Create flow ([#11696](https://github.com/linode/manager/pull/11696)) +- 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)) +- 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: + +- 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 regression ([#11732](https://github.com/linode/manager/pull/11732)) +- 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)) +- Incorrect default color shown in Avatar color picker ([#11787](https://github.com/linode/manager/pull/11787)) + +### 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)) + +### 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)) +- 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)) +- Add 4.0.0 Design Tokens - New Spacing & Badge Tokens ([#11757](https://github.com/linode/manager/pull/11757)) +- Update `react-vnc` to 3.0.7 ([#11758](https://github.com/linode/manager/pull/11758)) +- Update `jspdf` dependencies to resolve DOMPurify Dependabot alert ([#11768](https://github.com/linode/manager/pull/11768)) +- Update `Shiki` to 3.1.0 ([#11772](https://github.com/linode/manager/pull/11772)) + +### 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 CloudPulse Create Alerts ([#11670](https://github.com/linode/manager/pull/11670)) +- Apply new custom `eslint` rule and lint files (#11689, #11722, #11730, #11756) +- 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 edit functionality of user defined CloudPulse 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)) +- Delete region test suite ([#11780](https://github.com/linode/manager/pull/11780)) + +### 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)) +- 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)) +- Fix CloudPulse document titles with appropriate keywords (#11662) +- Update LKE checkout bar & NodeBalancer details summary ([#11653](https://github.com/linode/manager/pull/11653)) +- + ## [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..3410dd7ad46 --- /dev/null +++ b/packages/manager/cypress/e2e/core/account/account-linode-managed.spec.ts @@ -0,0 +1,179 @@ +/** + * @file Integration tests for Cloud Manager account enable Linode Managed flows. + */ + +import { profileFactory } from 'src/factories/profile'; +import { accountFactory } from 'src/factories/account'; +import { linodeFactory } from 'src/factories/linodes'; +import { chooseRegion } from 'support/util/regions'; +import { Linode } from '@linode/api-v4'; +import { + mockGetAccount, + mockEnableLinodeManaged, + mockEnableLinodeManagedError, +} from 'support/intercepts/account'; +import { mockGetLinodes } from 'support/intercepts/linodes'; +import { + linodeEnabledMessageText, + linodeManagedStateMessageText, +} from 'support/constants/account'; +import { mockGetProfile } from 'support/intercepts/profile'; +import { ui } from 'support/ui'; +import { + visitUrlWithManagedDisabled, + visitUrlWithManagedEnabled, +} from 'support/api/managed'; + +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({ + username: 'mock-user', + restricted: false, + }); + 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({ + username: 'mock-restricted-user', + restricted: true, + }); + 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({ + username: 'mock-user', + restricted: false, + }); + + 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..5f6e29425bf 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, mockInitiateEntityTransferError, + mockReceiveEntityTransfer, + mockGetEntityTransfersError, } 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..f4d90998420 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,22 +32,22 @@ 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', }, ]; 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-show-details.spec.ts b/packages/manager/cypress/e2e/core/cloudpulse/alert-show-details.spec.ts index 2febd83fe46..5e921b0a002 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; @@ -225,10 +227,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 +240,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..ceb7c10d904 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'; + +import type { Alert } from '@linode/api-v4'; +import type { Flags } from 'src/featureFlags'; -const flags: Partial = { aclp: { enabled: true, beta: true } }; +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]) @@ -170,22 +170,22 @@ describe('Integration Tests for CloudPulse Alerts Listing Page', () => { 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); @@ -263,8 +263,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 +296,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 +305,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..66aeae2d68b 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(); @@ -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') @@ -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..89d0c23ca31 100644 --- a/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts +++ b/packages/manager/cypress/e2e/core/cloudpulse/cloudpulse-navigation.spec.ts @@ -2,11 +2,12 @@ * @file Integration tests for CloudPulse 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', () => { 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..281e69dd407 --- /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('/monitor/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', 'monitor/alerts/definitions/create'); + }); + + it('should successfully create a new alert', () => { + cy.visitWithLogin('monitor/alerts/definitions/create'); + + // Enter Name and Description + cy.findByPlaceholderText('Enter Name') + .should('be.visible') + .type(customAlertDefinition.label); + + cy.findByPlaceholderText('Enter 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', 'monitor/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..7e2b870bace 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 ', () => { @@ -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..d78e9ce7631 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); @@ -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('Unselect 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, 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..cbb6451d06f --- /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(`/monitor/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(`/monitor/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', 'monitor/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..f3735d6413a 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 @@ -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..dd6638e8fa9 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,8 +234,8 @@ 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]); @@ -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..a25682e6111 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 { 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 { + LinodeConfigInterfaceFactory, 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. @@ -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..070c484fc37 100644 --- a/packages/manager/cypress/e2e/core/images/create-image.spec.ts +++ b/packages/manager/cypress/e2e/core/images/create-image.spec.ts @@ -31,8 +31,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 +54,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/machine-image-upload.spec.ts b/packages/manager/cypress/e2e/core/images/machine-image-upload.spec.ts index 8f8e2ecbf55..4f968e849ef 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 @@ -116,11 +116,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(); @@ -195,15 +195,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') 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..59c107e474c 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,4 +1,10 @@ -import { eventFactory, linodeFactory } from 'src/factories'; +import { + accountUserFactory, + eventFactory, + grantsFactory, + linodeFactory, + profileFactory, +} from 'src/factories'; import { linodeDiskFactory } from 'src/factories/disk'; import { imageFactory } from 'src/factories/images'; import { mockGetEvents } from 'support/intercepts/events'; @@ -6,6 +12,11 @@ import { mockCreateImage } from 'support/intercepts/images'; import { mockGetLinodeDisks, mockGetLinodes } from 'support/intercepts/linodes'; import { ui } from 'support/ui'; import { randomLabel, randomNumber, randomPhrase } from 'support/util/random'; +import { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; +import { mockGetUser } from 'support/intercepts/account'; describe('create image (using mocks)', () => { it('create image from a linode', () => { @@ -72,8 +83,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 +120,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({ + username: randomLabel(), + restricted: true, + }); + + const mockUser = accountUserFactory.build({ + username: mockProfile.username, + restricted: true, + user_type: 'default', + }); + + const mockGrants = grantsFactory.build({ + global: { + add_images: false, + }, + }); + + const mockDisks = [ + linodeDiskFactory.build({ label: 'Debian 12 Disk', filesystem: 'ext4' }), + linodeDiskFactory.build({ + label: '512 MB Swap Image', + filesystem: 'swap', + }), + ]; + + 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({ + username: randomLabel(), + restricted: true, + }); + + const mockUser = accountUserFactory.build({ + username: mockProfile.username, + restricted: true, + user_type: 'default', + }); + + 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..ca8c229fea5 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-create.spec.ts @@ -48,6 +48,10 @@ import { mockGetLinodeTypes } from 'support/intercepts/linodes'; import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; import { chooseRegion } from 'support/util/regions'; import { getTotalClusterMemoryCPUAndStorage } from 'src/features/Kubernetes/kubeUtils'; +import { + CLUSTER_TIER_DOCS_LINK, + CLUSTER_VERSIONS_DOCS_LINK, +} from 'src/features/Kubernetes/constants'; import { getTotalClusterPrice } from 'src/utilities/pricing/kubernetes'; import type { ExtendedType } from 'src/utilities/extendType'; @@ -104,6 +108,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 +164,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 +249,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 +405,8 @@ describe('LKE Cluster Creation with APL enabled', () => { )?.region_prices, vcpus: 8, }); - const mockedLKEClusterTypes = [ + + const mockedAPLLKEClusterTypes = [ dedicatedType, dedicated4Type, dedicated8Type, @@ -401,7 +436,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'); @@ -536,15 +571,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 +601,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') @@ -703,18 +732,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 +758,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') @@ -813,17 +836,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 +861,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 +884,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') @@ -935,17 +950,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 +975,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 +984,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 +994,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 +1015,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 +1086,7 @@ 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 at least one IP must be provided for ACL * - 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 @@ -1090,11 +1100,14 @@ describe('LKE Cluster Creation with LKE-E', () => { k8s_version: latestEnterpriseTierKubernetesVersion.id, }); const mockedEnterpriseClusterPools = [nanodeMemoryPool, dedicatedCpuPool]; - const mockedLKEClusterTypes = [dedicatedType, nanodeType]; 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 +1154,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 +1195,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 +1205,23 @@ 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 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 +1260,15 @@ 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'); + // Try to submit the form ui.button .findByTitle('Create Cluster') .should('be.visible') @@ -1252,6 +1276,33 @@ describe('LKE Cluster Creation with LKE-E', () => { .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(() => { + // Successfully submit the form + ui.button + .findByTitle('Create Cluster') + .should('be.visible') + .should('be.enabled') + .click(); + }); + + cy.findByText( + 'At least one IP address or CIDR range is required for LKE Enterprise.' + ).should('not.exist'); + // Wait for LKE cluster to be created and confirm that we are redirected // to the cluster summary page. cy.wait([ 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..6ea059173ed 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-delete.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-delete.spec.ts @@ -18,10 +18,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..659e5ad8c04 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 @@ -250,7 +250,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 +264,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 +319,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 +333,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..b2ef694aa31 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 @@ -120,8 +120,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 @@ -191,8 +191,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 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..edb21bc4286 100644 --- a/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts +++ b/packages/manager/cypress/e2e/core/kubernetes/lke-update.spec.ts @@ -46,6 +46,7 @@ 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 { DateTime } from 'luxon'; const mockNodePools = nodePoolFactory.buildList(2); @@ -81,7 +82,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 +154,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 +180,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 +203,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 +277,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 +311,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 +334,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'); @@ -390,10 +397,8 @@ describe('LKE cluster updates', () => { }); 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 +423,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 +453,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 +483,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'); }); @@ -541,30 +559,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 +606,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') @@ -705,19 +717,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 +774,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 +972,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 +1010,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. @@ -1369,7 +1384,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 +1420,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 +1544,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 +1558,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 +1573,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 +1592,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(); @@ -1779,6 +1799,8 @@ describe('LKE cluster updates', () => { it('filters the node tables based on selected status filter', () => { const mockCluster = kubernetesClusterFactory.build({ k8s_version: latestKubernetesVersion, + created: DateTime.local().toISO(), + tier: 'enterprise', }); const mockNodePools = [ nodePoolFactory.build({ @@ -1789,6 +1811,7 @@ describe('LKE cluster updates', () => { ], }), nodePoolFactory.build({ + count: 2, nodes: kubeLinodeFactory.buildList(2), }), ]; @@ -1823,6 +1846,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 +1861,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 +1876,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 +1894,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 +1909,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); }); @@ -1985,25 +2023,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 +2051,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 @@ -2110,7 +2148,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. @@ -2234,27 +2273,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') @@ -2349,7 +2388,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. @@ -2479,14 +2519,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 @@ -2559,16 +2598,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,10 +2645,10 @@ 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 }, @@ -2686,8 +2725,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 +2734,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') @@ -2811,13 +2850,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 @@ -2877,17 +2916,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 +2934,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 +2961,77 @@ 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: ['127.0.0.1'], ipv6: undefined }, + enabled: true, + }); + const mockControlPaneACL = kubernetesControlPlaneACLFactory.build({ + acl: mockACLOptions, + }); + + mockGetCluster(mockEnterpriseCluster).as('getCluster'); + mockGetControlPlaneACL(mockEnterpriseCluster.id, mockControlPaneACL).as( + 'getControlPlaneACL' + ); + + cy.visitWithLogin(`/kubernetes/clusters/${mockEnterpriseCluster.id}`); + cy.wait(['@getAccount', '@getCluster', '@getControlPlaneACL']); + + cy.contains('Control Plane ACL').should('be.visible'); + 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('IPv4 Addresses or CIDRs ip-address-0') + .should('be.visible') + .click(); + cy.focused().clear(); + + // 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.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'); + }); + }); }); }); 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..124c9d04659 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 @@ -61,14 +61,13 @@ describe('LKE Create Cluster', () => { 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(); 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..2408d15a7db 100644 --- a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts @@ -202,10 +202,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') 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..e964c7a2046 100644 --- a/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/clone-linode.spec.ts @@ -41,6 +41,7 @@ import { } from 'support/constants/linodes'; import type { Linode } from '@linode/api-v4'; import { mockGetLinodeConfigs } from 'support/intercepts/configs'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; /** * Returns the Cloud Manager URL to clone a given Linode. @@ -60,6 +61,11 @@ describe('clone linode', () => { before(() => { cleanUp('linodes'); }); + beforeEach(() => { + mockAppendFeatureFlags({ + linodeInterfaces: { enabled: false }, + }); + }); /* * - Confirms Linode Clone flow via the Linode details page. @@ -215,11 +221,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-mobile.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-mobile.spec.ts index 608791634f8..b456ec593aa 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 @@ -35,13 +35,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-view-code-snippet.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-view-code-snippet.spec.ts index e15151274c0..7d6440ee412 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 @@ -6,8 +6,14 @@ import { ui } from 'support/ui'; import { randomLabel, randomString } from 'support/util/random'; import { linodeCreatePage } from 'support/ui/pages'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; 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..8e87638090f 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 @@ -37,11 +37,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 +90,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-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..087001c9125 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 @@ -17,8 +17,14 @@ 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 { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; 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 +57,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 +66,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 +132,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 +147,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 +156,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 +270,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..1ad12b3382b 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 @@ -47,8 +47,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(() => { @@ -113,8 +113,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 +148,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-vlan.spec.ts b/packages/manager/cypress/e2e/core/linodes/create-linode-with-vlan.spec.ts index 270b9de1072..12929833fe4 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 @@ -11,8 +11,15 @@ import { } from 'support/util/random'; import { mockGetVLANs } from 'support/intercepts/vlans'; import { mockCreateLinode } from 'support/intercepts/linodes'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; 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. @@ -67,11 +74,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') @@ -151,11 +157,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..7ecdd0a8f67 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 @@ -30,8 +30,14 @@ import { } from 'support/util/random'; import { chooseRegion } from 'support/util/regions'; import { WARNING_ICON_UNRECOMMENDED_CONFIG } from 'src/features/VPCs/constants'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; 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. @@ -96,7 +102,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 +117,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 @@ -236,7 +242,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 +343,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..30bd5352499 100644 --- a/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/create-linode.spec.ts @@ -23,6 +23,9 @@ import { Region, VLAN, Config, Disk } from '@linode/api-v4'; import { getRegionById } from 'support/util/regions'; import { accountFactory, + accountUserFactory, + profileFactory, + grantsFactory, linodeFactory, linodeConfigFactory, linodeTypeFactory, @@ -40,6 +43,11 @@ 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 { + mockGetProfile, + mockGetProfileGrants, +} from 'support/intercepts/profile'; +import { mockGetUser } from 'support/intercepts/account'; let username: string; @@ -49,6 +57,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. @@ -106,13 +119,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 @@ -229,13 +241,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 @@ -379,10 +390,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 +408,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 +424,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 +439,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 +523,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({ + username: randomLabel(), + restricted: true, + }); + + const mockUser = accountUserFactory.build({ + username: mockProfile.username, + restricted: true, + user_type: 'default', + }); + + 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..487e78259f4 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -344,9 +344,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') @@ -542,9 +543,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 +558,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 @@ -653,10 +656,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') @@ -758,19 +760,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 +778,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 +798,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..c960beada73 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-network.spec.ts @@ -18,6 +18,7 @@ import { mockAddFirewallDevice, mockGetFirewalls, } from 'support/intercepts/firewalls'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; describe('IP Addresses', () => { const mockLinode = linodeFactory.build(); @@ -45,6 +46,9 @@ describe('IP Addresses', () => { }); beforeEach(() => { + mockAppendFeatureFlags({ + linodeInterfaces: { enabled: false }, + }); mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinode'); mockGetLinodeFirewalls(mockLinode.id, []).as('getLinodeFirewalls'); mockGetLinodeIPAddresses(mockLinode.id, { @@ -143,6 +147,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 +222,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..2a2d971c0cc 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-storage.spec.ts @@ -117,7 +117,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 +236,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/rebuild-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts index 296ac8ae6b3..568dfffba1f 100644 --- a/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/rebuild-linode.spec.ts @@ -3,7 +3,10 @@ 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 { + interceptGetStackScript, + interceptGetStackScripts, +} from 'support/intercepts/stackscripts'; import { createLinodeRequestFactory, linodeFactory } from '@src/factories'; import { cleanUp } from 'support/util/cleanup'; import { chooseRegion } from 'support/util/regions'; @@ -72,10 +75,8 @@ const assertPasswordComplexity = ( desiredPassword: string, passwordStrength: 'Weak' | 'Fair' | 'Good' ) => { - 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 +88,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 +134,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 +168,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 +183,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 +191,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') @@ -267,22 +271,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 +321,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,10 +331,8 @@ 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'); 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..5f7486eb352 100644 --- a/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/resize-linode.spec.ts @@ -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/smoke-delete-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/smoke-delete-linode.spec.ts index 506bb09b592..bb1310c840c 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 @@ -31,10 +31,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') @@ -96,10 +94,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 +143,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 +186,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..8826771ff3d 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 @@ -8,7 +8,10 @@ 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 { + mockGetLinodes, + mockGetLinodeFirewalls, +} from 'support/intercepts/linodes'; import { userPreferencesFactory, profileFactory } from '@src/factories'; import { accountUserFactory } from '@src/factories/accountUsers'; import { grantsFactory } from '@src/factories/grants'; @@ -22,6 +25,7 @@ import { import { randomLabel } from 'support/util/random'; import * as commonLocators from 'support/ui/locators/common-locators'; import * as linodeLocators from 'support/ui/locators/linode-locators'; +import { mockAppendFeatureFlags } from 'support/intercepts/feature-flags'; const mockLinodes = new Array(5).fill(null).map( (_item: null, index: number): Linode => { @@ -91,7 +95,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'); }); @@ -392,6 +397,10 @@ describe('linode landing checks', () => { }); it('checks summary view for linode table', () => { + mockAppendFeatureFlags({ + linodeInterfaces: { enabled: false }, + }); + const mockPreferencesListView = userPreferencesFactory.build(); const mockPreferencesSummaryView = { @@ -405,6 +414,10 @@ describe('linode landing checks', () => { 'updateUserPreferences' ); + mockLinodes.forEach((linode) => { + mockGetLinodeFirewalls(linode.id, []); + }); + cy.visitWithLogin('/linodes'); cy.wait(['@getLinodes', '@getUserPreferences']); 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..64f64742ab0 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 @@ -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.spec.ts b/packages/manager/cypress/e2e/core/longview/longview.spec.ts index ccd872b7aa7..78db13b4ed6 100644 --- a/packages/manager/cypress/e2e/core/longview/longview.spec.ts +++ b/packages/manager/cypress/e2e/core/longview/longview.spec.ts @@ -321,7 +321,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..63d853cf8a8 100644 --- a/packages/manager/cypress/e2e/core/managed/managed-contacts.spec.ts +++ b/packages/manager/cypress/e2e/core/managed/managed-contacts.spec.ts @@ -86,18 +86,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') @@ -173,21 +171,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 +237,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..9db0fe50925 100644 --- a/packages/manager/cypress/e2e/core/managed/managed-credentials.spec.ts +++ b/packages/manager/cypress/e2e/core/managed/managed-credentials.spec.ts @@ -76,20 +76,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 +146,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 +162,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 +224,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..33227ae887a 100644 --- a/packages/manager/cypress/e2e/core/managed/managed-monitors.spec.ts +++ b/packages/manager/cypress/e2e/core/managed/managed-monitors.spec.ts @@ -94,11 +94,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'); @@ -183,14 +181,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') @@ -249,10 +245,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-ssh.spec.ts b/packages/manager/cypress/e2e/core/managed/managed-ssh.spec.ts index 1dd368a3702..dab96aaae79 100644 --- a/packages/manager/cypress/e2e/core/managed/managed-ssh.spec.ts +++ b/packages/manager/cypress/e2e/core/managed/managed-ssh.spec.ts @@ -135,23 +135,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/nodebalancers/nodebalancers-create-in-complex-form.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/nodebalancers-create-in-complex-form.spec.ts index 1856008d777..f5e22d67fe4 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 @@ -55,42 +55,34 @@ describe('create NodeBalancer to test the submission of multiple nodes and multi 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 +90,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(() => { @@ -155,24 +147,20 @@ describe('create NodeBalancer to test the submission of multiple nodes and multi 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 +171,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') @@ -236,24 +220,20 @@ describe('create NodeBalancer to test the submission of multiple nodes and multi }); 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 +244,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/smoke-create-nodebal.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts index 002eb96f629..9022ab6b9f7 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 @@ -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(); @@ -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(); 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..909218b1afd 100644 --- a/packages/manager/cypress/e2e/core/notificationsAndEvents/events-menu.spec.ts +++ b/packages/manager/cypress/e2e/core/notificationsAndEvents/events-menu.spec.ts @@ -51,8 +51,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'); }); }); }); @@ -102,8 +103,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. 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..5f851b1f7cc 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 @@ -57,7 +57,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') @@ -157,7 +158,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..6f0710a2aa3 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 @@ -51,10 +51,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(); 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..3792b228802 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 @@ -107,8 +107,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.findByText('Label').click(); + cy.focused().type(bucketLabel); + ui.regionSelect.find().click(); + cy.focused().type(`${bucketRegion}{enter}`); ui.buttonGroup .findButtonByTitle('Create Bucket') @@ -133,7 +135,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 +181,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..21edeaed94c 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 @@ -62,8 +62,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.findByText('Label').click(); + cy.focused().type(bucketLabel); + ui.regionSelect.find().click(); + cy.focused().type(`${bucketRegion}{enter}`); ui.buttonGroup .findButtonByTitle('Create Bucket') .should('be.visible') @@ -199,7 +201,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..a031285e701 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 @@ -126,7 +126,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..22b5ce009b5 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 @@ -97,8 +97,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 { @@ -186,8 +184,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.findByText('Label').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 +264,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') @@ -324,8 +325,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.findByText('Label').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 +390,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') @@ -447,8 +451,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.findByText('Label').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 +514,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') @@ -568,8 +575,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.findByText('Label').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 +638,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') @@ -703,7 +713,8 @@ describe('Object Storage Gen2 create bucket tests', () => { .click(); cy.contains('Label is required.').should('be.visible'); - cy.findByText('Label').click().type(bucketLabel); + cy.findByText('Label').click(); + cy.focused().type(bucketLabel); cy.contains('Label is required.').should('not.exist'); // confirms (mock) API error appears @@ -763,10 +774,10 @@ describe('Object Storage Gen2 create bucket modal has disabled fields for restri .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/objectStorageMulticluster/access-keys-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/access-keys-multicluster.spec.ts index 56e1e24d44f..790f12ff31e 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 @@ -87,10 +87,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 +102,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 @@ -205,25 +201,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(); @@ -364,16 +354,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..a3ad4d53145 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 @@ -76,9 +76,11 @@ describe('Object Storage Multicluster Bucket create', () => { .should('be.visible') .within(() => { // Enter label. - cy.contains('Label').click().type(mockBucket.label); + cy.contains('Label').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..669aea293c3 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 @@ -51,7 +51,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/object-storage-objects-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts index 1138f4f99dc..0281cfa2d35 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 @@ -185,10 +185,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 +226,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 +278,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..827533a2bfe 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 @@ -85,11 +85,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 @@ -187,20 +186,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 +211,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 +226,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 +277,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..c14eda35f46 100644 --- a/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/account-switching.spec.ts @@ -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'); 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..2173815b7a2 100644 --- a/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/token-expiration.spec.ts @@ -77,6 +77,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..845701b9c90 100644 --- a/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts +++ b/packages/manager/cypress/e2e/core/parentChild/token-scopes.spec.ts @@ -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/volumes/create-volume.spec.ts b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts index ceac6fc7cf1..5f2f2f6931b 100644 --- a/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts +++ b/packages/manager/cypress/e2e/core/volumes/create-volume.spec.ts @@ -22,6 +22,7 @@ import { mockGetLinodeDetails, mockGetLinodes, } from 'support/intercepts/linodes'; +import { entityTag } from 'support/constants/cypress'; // 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 +52,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'); @@ -71,6 +73,7 @@ describe('volume create flow', () => { // Fill out and submit volume create form. cy.contains('Label').click().type(volume.label); + cy.findByLabelText('Tags').click().type(entityTag); cy.contains('Size').click().type(`{selectall}{backspace}${volume.size}`); ui.regionSelect.find().click().type(`${volume.region}{enter}`); 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..18ff5ac6909 100644 --- a/packages/manager/cypress/support/component/setup.tsx +++ b/packages/manager/cypress/support/component/setup.tsx @@ -15,6 +15,13 @@ import { queryClientFactory } from '@src/queries/base'; 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..2783c154f5c 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,8 @@ export const aggregationTypeMap: Record = { min: 'Minimum', sum: 'Sum', }; + +export const statusMap: Record = { + disabled: 'Disabled', + enabled: 'Enabled', +}; 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/linodes.ts b/packages/manager/cypress/support/intercepts/linodes.ts index d46bcc8dd1b..2d806f28c6f 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) ); }; 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/package.json b/packages/manager/package.json index 2f7f2218c2b..687b41f66ff 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,22 +23,25 @@ "@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/search": "workspace:*", + "@linode/ui": "workspace:*", + "@linode/validation": "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", @@ -53,8 +57,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.0", + "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 +77,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 +96,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,11 +118,14 @@ "*.{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", + "@linode/eslint-plugin-cloud-manager": "^0.0.7", "@storybook/addon-a11y": "^8.4.7", "@storybook/addon-actions": "^8.4.7", "@storybook/addon-controls": "^8.4.7", @@ -145,8 +150,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 +174,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,15 +194,15 @@ "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", @@ -204,7 +210,7 @@ "redux-mock-store": "^1.5.3", "storybook": "^8.4.7", "storybook-dark-mode": "4.0.1", - "vite": "^6.0.11", + "vite": "^6.1.1", "vite-plugin-svgr": "^3.2.0" }, "browserslist": [ diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index 5b8bc4b2009..ebe5c399f1d 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'; @@ -162,7 +162,6 @@ 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')); @@ -384,7 +383,6 @@ export const MainContent = () => { - {isDatabasesEnabled && ( )} diff --git a/packages/manager/src/Root.tsx b/packages/manager/src/Root.tsx index 9d3a34c061a..1e8202ef083 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'; 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..d9d10a0becb 100644 --- a/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx +++ b/packages/manager/src/components/AbuseTicketBanner/AbuseTicketBanner.tsx @@ -1,5 +1,5 @@ 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'; @@ -36,7 +36,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..5f02af4faee 100644 --- a/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx +++ b/packages/manager/src/components/AccessPanel/UserSSHKeyPanel.tsx @@ -1,4 +1,4 @@ -import { Button, Checkbox, Typography } from '@linode/ui'; +import { Box, Button, Checkbox, Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -20,9 +20,10 @@ 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 +47,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 +203,8 @@ const UserSSHKeyPanel = (props: Props) => { }; return ( - - + + SSH Keys @@ -227,8 +238,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/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..06a0957d235 100644 --- a/packages/manager/src/components/Breadcrumb/Crumbs.tsx +++ b/packages/manager/src/components/Breadcrumb/Crumbs.tsx @@ -1,6 +1,5 @@ -import { LocationDescriptor } from 'history'; import * as React from 'react'; -import { Link } from 'react-router-dom'; +import { Link, LinkProps } from 'react-router-dom'; import { StyledDiv, @@ -9,11 +8,11 @@ import { } from './Crumbs.styles'; import { FinalCrumb } from './FinalCrumb'; import { FinalCrumbPrefix } from './FinalCrumbPrefix'; -import { EditableProps, LabelProps } from './types'; +import type { EditableProps, LabelProps } from './types'; 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/CopyableTextField/CopyableTextField.test.tsx b/packages/manager/src/components/CopyableTextField/CopyableTextField.test.tsx index 0c849c41cbb..e065cb36c48 100644 --- a/packages/manager/src/components/CopyableTextField/CopyableTextField.test.tsx +++ b/packages/manager/src/components/CopyableTextField/CopyableTextField.test.tsx @@ -1,14 +1,14 @@ +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', () => ({ downloadFile: vi.fn(), })); diff --git a/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx b/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx index 58470598a58..5f7928afa03 100644 --- a/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx +++ b/packages/manager/src/components/DeletionDialog/DeletionDialog.test.tsx @@ -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..efed45049e3 100644 --- a/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx +++ b/packages/manager/src/components/DeletionDialog/DeletionDialog.tsx @@ -1,4 +1,5 @@ import { Notice, Typography } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; @@ -7,7 +8,6 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati 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 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/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/Drawer.tsx b/packages/manager/src/components/Drawer.tsx index 4b3259f9110..2aa1c8ab5d3 100644 --- a/packages/manager/src/components/Drawer.tsx +++ b/packages/manager/src/components/Drawer.tsx @@ -1,17 +1,17 @@ import { Box, CircleProgress, + ErrorState, IconButton, Typography, convertForAria, } from '@linode/ui'; import Close from '@mui/icons-material/Close'; import _Drawer from '@mui/material/Drawer'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { ErrorState } from './ErrorState/ErrorState'; import { NotFound } from './NotFound'; import type { APIError } from '@linode/api-v4'; @@ -97,14 +97,14 @@ export const Drawer = React.forwardRef( role="dialog" > {isFetching ? null : ( 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..9a0f9004d3d 100644 --- a/packages/manager/src/components/Encryption/utils.ts +++ b/packages/manager/src/components/Encryption/utils.ts @@ -1,6 +1,14 @@ 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. * Based on the user's account capability and the feature flag. @@ -58,3 +66,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/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/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts b/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts index 434c4f04cd0..7d866302135 100644 --- a/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts +++ b/packages/manager/src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate.ts @@ -45,14 +45,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 +62,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..c65c091fa05 100644 --- a/packages/manager/src/components/IPSelect/IPSelect.tsx +++ b/packages/manager/src/components/IPSelect/IPSelect.tsx @@ -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..a56a352e89a 100644 --- a/packages/manager/src/components/IconTextLink/IconTextLink.tsx +++ b/packages/manager/src/components/IconTextLink/IconTextLink.tsx @@ -18,23 +18,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 +29,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', + alignItems: 'center', borderRadius: theme.tokens.borderRadius.None, - cursor: 'pointer', 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 +43,6 @@ export interface Props { children?: string; className?: string; disabled?: boolean; - hideText?: boolean; - left?: boolean; onClick?: () => void; text: string; title: string; @@ -83,8 +56,6 @@ export const IconTextLink = (props: Props) => { active, className, disabled, - hideText, - left, onClick, text, title, @@ -98,7 +69,6 @@ export const IconTextLink = (props: Props) => { { [classes.active]: active, [classes.disabled]: disabled, - [classes.left]: left, }, className )} @@ -107,14 +77,8 @@ export const IconTextLink = (props: Props) => { onClick={onClick} title={title} > - - - {text} - + + {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 && ( { 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/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..7d5b723919f 100644 --- a/packages/manager/src/components/MainContentBanner.test.tsx +++ b/packages/manager/src/components/MainContentBanner.test.tsx @@ -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/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..b8d31f626b6 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/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) => ( + + + + + {quota.quota_name} + + + + + {quota.quota_limit} + + + {quotaUsageQueries[index]?.isLoading ? ( + + {' '} + Fetching Data... + + ) : quotaUsageQueries[index]?.error ? ( + + + {getQuotaError(quotaUsageQueries, index)} + + ) : quota.usage?.used !== null ? ( + <> + + + {`${quota.usage?.used} of ${quota.quota_limit} ${ + quota.resource_metric + }${quota.quota_limit > 1 ? 's' : ''} used`} + + + ) : ( + Data not available + )} + + + + {}} + /> + + + )) + )} + +
+ {selectedLocation && !isFetchingQuotas && ( + + )} + + ); +}; 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..69f6fb1d3ac --- /dev/null +++ b/packages/manager/src/features/Account/Quotas/utils.test.tsx @@ -0,0 +1,88 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook } from '@testing-library/react'; +import * as React from 'react'; + +import { getQuotaError, 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'); + }); +}); 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..d158625b150 --- /dev/null +++ b/packages/manager/src/features/Account/Quotas/utils.ts @@ -0,0 +1,111 @@ +import { + GLOBAL_QUOTA_LABEL, + GLOBAL_QUOTA_VALUE, +} from 'src/components/RegionSelect/constants'; +import { regionSelectGlobalOption } from 'src/components/RegionSelect/constants'; +import { useObjectStorageEndpoints } from 'src/queries/object-storage/queries'; +import { useRegionsQuery } from 'src/queries/regions/regions'; + +import type { + Filter, + 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'; +}; diff --git a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx index c9b32879308..15ad5c5fdd1 100644 --- a/packages/manager/src/features/Account/SwitchAccountDrawer.tsx +++ b/packages/manager/src/features/Account/SwitchAccountDrawer.tsx @@ -6,15 +6,14 @@ import { Drawer } from 'src/components/Drawer'; 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, diff --git a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx index 9bb00b35195..8356ec80226 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/SessionExpirationDialog.tsx @@ -1,4 +1,5 @@ import { Typography } from '@linode/ui'; +import { useInterval } from '@linode/utilities'; import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; @@ -7,7 +8,6 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati 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 { parseAPIDate } from 'src/utilities/date'; import { pluralize } from 'src/utilities/pluralize'; diff --git a/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx b/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx index 5924c18ec8c..4a90c7c5dfc 100644 --- a/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx +++ b/packages/manager/src/features/Account/SwitchAccounts/useParentChildAuthentication.tsx @@ -9,14 +9,14 @@ import { 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.tsx b/packages/manager/src/features/Backups/BackupDrawer.tsx index 7e8dfefa222..ed87f1849f7 100644 --- a/packages/manager/src/features/Backups/BackupDrawer.tsx +++ b/packages/manager/src/features/Backups/BackupDrawer.tsx @@ -87,7 +87,7 @@ export const BackupDrawer = (props: Props) => { const renderBackupsTable = () => { if (linodesLoading || typesLoading || accountSettingsLoading) { - return ; + return ; } if (linodesError) { return ; 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/Billing/BillingDetail.tsx b/packages/manager/src/features/Billing/BillingDetail.tsx index df85a4e5040..b9e203ba713 100644 --- a/packages/manager/src/features/Billing/BillingDetail.tsx +++ b/packages/manager/src/features/Billing/BillingDetail.tsx @@ -1,12 +1,11 @@ -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'; diff --git a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx index b1cd0138ae2..2c1e6b6590d 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingActivityPanel/BillingActivityPanel.tsx @@ -2,7 +2,7 @@ import { getInvoiceItems } from '@linode/api-v4/lib/account'; import { Autocomplete, Typography } 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 { DateTime } from 'luxon'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -385,7 +385,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 +154,20 @@ export const BillingSummary = (props: BillingSummaryProps) => { return ( <> - - + + Account Balance @@ -202,7 +212,13 @@ export const BillingSummary = (props: BillingSummaryProps) => { {promotions && promotions?.length > 0 ? ( - + Promotions @@ -218,7 +234,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..411181242a8 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,5 @@ import { CircleProgress, Tooltip } 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 { makeStyles } from 'tss-react/mui'; @@ -146,10 +146,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..698a42d7e44 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, @@ -220,10 +220,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/PaymentDrawer.tsx b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx index 8cd4021fa94..1a5b92b7ab3 100644 --- a/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/BillingSummary/PaymentDrawer/PaymentDrawer.tsx @@ -3,13 +3,14 @@ import { Typography } from '@linode/ui'; import { Button, Divider, + ErrorState, InputAdornment, Notice, Stack, TextField, TooltipIcon, } 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'; @@ -17,7 +18,6 @@ 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 { SupportLink } from 'src/components/SupportLink'; import { getRestrictedResourceText } from 'src/features/Account/utils'; @@ -343,7 +343,12 @@ export const PaymentDrawer = (props: Props) => { - + { /> - + { const renderVariant = () => { return is_default ? ( - + ) : null; }; return ( - + { }; return ( - + Billing Contact diff --git a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx index ab564bce546..f1daef16f1d 100644 --- a/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx +++ b/packages/manager/src/features/Billing/BillingPanels/ContactInfoPanel/UpdateContactInformationForm/UpdateContactInformationForm.tsx @@ -5,7 +5,7 @@ import { TextField, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { allCountries } from 'country-region-data'; import { useFormik } from 'formik'; import * as React from 'react'; @@ -223,7 +223,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 +392,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} + size={12} + sx={{ + alignItems: 'flex-start', + display: 'flex', + marginTop: (theme) => theme.tokens.spacing.S16, + }} > 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..a4dbe1f96f8 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,7 @@ import { addPaymentMethod } from '@linode/api-v4/lib'; import { 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'; @@ -165,12 +165,12 @@ const AddCreditCardForm = (props: Props) => { return (
{error && ( - + )} - + { value={values.card_number} /> - + { placeholder="MM/YY" /> - + { /> ) : null} {isReadOnly && ( - + { - + Google Pay You’ll be taken to Google Pay to complete sign up. @@ -131,11 +136,15 @@ export const AddPaymentMethodDrawer = (props: Props) => { {!isReadOnly && ( { - + PayPal You’ll be taken to PayPal to complete sign up. @@ -159,11 +173,15 @@ export const AddPaymentMethodDrawer = (props: Props) => { {!isReadOnly && ( { }, [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/InvoiceDetail/InvoiceDetail.tsx b/packages/manager/src/features/Billing/InvoiceDetail/InvoiceDetail.tsx index 45d5396ff5e..0e545580f8f 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'; @@ -125,10 +125,21 @@ export const InvoiceDetail = () => { padding: `${theme.spacing(2)} ${theme.spacing(3)}`, }} > - - + + - + { {account && invoice && items && ( <> @@ -188,7 +201,11 @@ export const InvoiceDetail = () => { )} - + {invoice && ( Total:{' '} @@ -201,7 +218,7 @@ export const InvoiceDetail = () => { - + {pdfGenerationError && ( Failed generating PDF. )} @@ -212,7 +229,7 @@ export const InvoiceDetail = () => { shouldShowRegion={shouldShowRegion} /> - + {invoice && ( { service_type: alertServiceType, type, } = alertDetails; + return ( <> + 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, + }} > { return ( @@ -24,7 +24,7 @@ export const AlertDefinitionLanding = () => { exact path="/monitor/alerts/definitions/edit/:serviceType/:alertId" > - + { }; return ( - - - + + + - - - - - - - - + + + + + + + + + + ); }); diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx index 0844f758b87..7ed0692caad 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListTable.tsx @@ -61,7 +61,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, }) @@ -99,7 +99,11 @@ export const AlertsListTable = React.memo((props: AlertsListTableProps) => { pageSize, }) => ( <> - + diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx index 3911fce0b2b..76e354a1303 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertListing.tsx @@ -4,7 +4,7 @@ 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'; @@ -117,9 +117,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" > 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..f51a69bfc23 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'; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx index 99059d706a2..6999349a6a0 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/AlertTableRow.tsx @@ -1,4 +1,5 @@ import { Box } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import * as React from 'react'; import { useLocation } from 'react-router-dom'; @@ -6,7 +7,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 { formatDate } from 'src/utilities/formatDate'; import { AlertActionMenu } from './AlertActionMenu'; diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts b/packages/manager/src/features/CloudPulse/Alerts/AlertsListing/constants.ts index 95dc2bb06bf..c9f86d4aac2 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 = [ @@ -30,3 +31,9 @@ export const statusToActionMap: Record< disabled: 'Enable', enabled: '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..eb68fd281db 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.test.tsx @@ -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(() => { diff --git a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx index 553758d83c3..0a1fa993940 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/AlertsResources.tsx @@ -2,8 +2,9 @@ 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'; @@ -13,11 +14,12 @@ import { getFilteredResources, getRegionOptions, getRegionsIdRegionMap, + getSupportedRegionIds, scrollToElement, } from '../Utils/AlertResourceUtils'; 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'; @@ -102,11 +104,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 +141,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, @@ -282,8 +310,13 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { )} ); @@ -306,14 +339,16 @@ 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 +401,18 @@ export const AlertResources = React.memo((props: AlertResourcesProp) => { /> )} - {isSelectionsNeeded && !isDataLoadingError && resources?.length && ( - - - - )} + {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/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..cab8b1e4331 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/AlertsResources/DisplayAlertResources.tsx @@ -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..838e4bb3db1 --- /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.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..d9e1a629d32 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.test.tsx @@ -0,0 +1,67 @@ +import { within } from '@testing-library/react'; +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'); + }); +}); 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..e186879c479 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertInformationActionTable.tsx @@ -0,0 +1,174 @@ +import { Box } from '@linode/ui'; +import { Grid, TableBody, TableHead } from '@mui/material'; +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 { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import { AlertInformationActionRow } from './AlertInformationActionRow'; + +import type { APIError, Alert } 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 interface AlertStatusMap { + /** + * Alert id to boolean mapping to hold whether that alert id is enabled or not + */ + [alertId: number]: boolean; +} + +// generate the mapping of id to whether entity present in entity_ids list or not for quick access in table row toggles +const generateStatusMap = ( + alerts: Alert[], + entityId: string +): AlertStatusMap => { + return alerts.reduce( + (previousValue, alert) => ({ + ...previousValue, + [alert.id]: alert.entity_ids.includes(entityId), + }), + {} + ); +}; + +export const AlertInformationActionTable = ( + props: AlertInformationActionTableProps +) => { + const { alerts, columns, entityId, error, orderByColumn } = props; + const [alertStatusMap, setAlertStatusMap] = React.useState( + generateStatusMap(alerts, entityId) + ); + + const _error = error + ? getAPIErrorOrDefault(error, 'Error while fetching the alerts') + : undefined; + + const handleToggle = ({ id }: Alert) => { + setAlertStatusMap((previousValue) => { + return { + ...previousValue, + [id]: !previousValue[id], + }; + }); + }; + 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..77c9ea4a844 --- /dev/null +++ b/packages/manager/src/features/CloudPulse/Alerts/ContextualView/AlertReusableComponent.test.tsx @@ -0,0 +1,109 @@ +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( + '/monitor/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..eb4a955c73a --- /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..2abd9b63f64 --- /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.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.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.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.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx index 4aa79c8d0d3..0971d003a6f 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/CreateAlertDefinition.tsx @@ -7,6 +7,7 @@ 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 { useCreateAlertDefinition } from 'src/queries/cloudpulse/alerts'; import { MetricCriteriaField } from './Criteria/MetricCriteria'; @@ -109,74 +110,77 @@ export const CreateAlertDefinition = () => { }); 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 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!} + /> + + + + +
+
+
); }; 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..95a16a1f587 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/DimensionFilter.tsx @@ -60,8 +60,9 @@ export const DimensionFilters = (props: DimensionFilterProps) => { } buttonType="secondary" compactX + data-qa-buttons="true" size="small" - sx={{ justifyContent: 'start' }} + sx={{ justifyContent: 'start', width: '150px' }} > 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..c2306ea0b09 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,6 +108,7 @@ 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} @@ -134,6 +140,7 @@ 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} @@ -168,6 +175,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.tsx b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx index c5fabddffaa..40c5f460626 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/CreateAlert/Criteria/Metric.tsx @@ -159,6 +159,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" @@ -191,6 +192,7 @@ export const Metric = (props: MetricCriteriaProps) => { 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} @@ -227,6 +229,7 @@ export const Metric = (props: MetricCriteriaProps) => { ) : null } + data-qa-metric-threshold={`${name}-operator`} data-testid="operator" disabled={!metricWatcher} errorText={fieldState.error?.message} @@ -251,6 +254,8 @@ export const Metric = (props: MetricCriteriaProps) => { onWheel={(event: React.SyntheticEvent) => event.target instanceof HTMLElement && event.target.blur() } + data-qa-metric-threshold={`${name}-threshold`} + data-qa-threshold="threshold" data-testid="threshold" errorText={fieldState.error?.message} label="Threshold" 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..b8ea2d60399 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 - + ( @@ -129,12 +135,14 @@ export const TriggerConditions = (props: TriggerConditionProps) => { /> @@ -144,24 +152,27 @@ export const TriggerConditions = (props: TriggerConditionProps) => { ( - - event.target instanceof HTMLElement && event.target.blur() - } - sx={{ - height: '30px', - width: '30px', - }} - data-testid="trigger-occurences" - errorText={fieldState.error?.message} - label="" - min={0} - name={`${name}.trigger_occurrences`} - onBlur={field.onBlur} - onChange={(e) => field.onChange(e.target.value)} - type="number" - value={field.value ?? 0} - /> + + + event.target instanceof HTMLElement && event.target.blur() + } + sx={{ + height: '30px', + width: '30px', + }} + data-qa-trigger-occurrences + data-testid="trigger-occurences" + errorText={fieldState.error?.message} + label="" + min={0} + name={`${name}.trigger_occurrences`} + onBlur={field.onBlur} + onChange={(e) => field.onChange(e.target.value)} + type="number" + value={field.value ?? 0} + /> + )} control={control} name={`${name}.trigger_occurrences`} 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..a7f4783362a 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,10 @@ import type { AlertServiceType } from '@linode/api-v4'; import type { FieldPathByValue } from 'react-hook-form'; interface CloudPulseServiceSelectProps { + /** + * 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 +23,7 @@ interface CloudPulseServiceSelectProps { export const CloudPulseServiceSelect = ( props: CloudPulseServiceSelectProps ) => { - const { name } = props; + const { isDisabled, name } = props; const { data: serviceOptions, error: serviceTypesError, @@ -60,11 +64,11 @@ export const CloudPulseServiceSelect = ( } }} 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..b2894389303 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 { capitalize } from '@linode/utilities'; import { 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'; 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..6e514e4426c 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 { capitalize } from '@linode/utilities'; import React from 'react'; import { 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'; @@ -94,20 +94,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 +117,10 @@ export const AddChannelListing = React.memo((props: AddChannelListingProps) => { - + To: - + {notification && } @@ -151,6 +152,7 @@ export const AddChannelListing = React.memo((props: AddChannelListingProps) => {
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..8832ebdaaf9 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResourcesConfirmationDialog.tsx +++ b/packages/manager/src/features/CloudPulse/Alerts/EditAlert/EditAlertResourcesConfirmationDialog.tsx @@ -7,6 +7,11 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import type { ActionPanelProps } from 'src/components/ActionsPanel/ActionsPanel'; 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 +30,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, }, 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..3b8e46442cf 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)); @@ -296,13 +327,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..45377d1a2af 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/AlertsActionMenu.ts @@ -28,6 +28,10 @@ export const getAlertTypeToActionsList = ( onClick: handleDetails, title: 'Show Details', }, + { + onClick: handleEdit, + title: 'Edit', + }, { onClick: handleEnableDisable, title: getTitleForEnableDisable(alertStatus), 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..585759df05b 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,14 @@ -import { serviceTypesFactory } from 'src/factories'; +import { alertFactory, serviceTypesFactory } from 'src/factories'; -import { convertSecondsToMinutes, getServiceTypeLabel } from './utils'; +import { + convertAlertDefinitionValues, + convertAlertsToTypeSet, + convertSecondsToMinutes, + filterAlertsByStatusAndType, + getServiceTypeLabel, +} from './utils'; + +import type { Alert, EditAlertPayloadWithService } from '@linode/api-v4'; it('test getServiceTypeLabel method', () => { const services = serviceTypesFactory.buildList(3); @@ -21,3 +29,53 @@ 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); +}); diff --git a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts index e139d601a05..90065cb8c1d 100644 --- a/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts +++ b/packages/manager/src/features/CloudPulse/Alerts/Utils/utils.ts @@ -1,5 +1,15 @@ +import { aggregationTypeMap, metricOperatorTypeMap } from '../constants'; + import type { AlertDimensionsProp } from '../AlertsDetail/DisplayAlertDetailChips'; -import type { NotificationChannel, ServiceTypesList } from '@linode/api-v4'; +import type { + Alert, + AlertDefinitionMetricCriteria, + AlertDefinitionType, + AlertServiceType, + EditAlertPayloadWithService, + NotificationChannel, + ServiceTypesList, +} from '@linode/api-v4'; import type { Theme } from '@mui/material'; interface AlertChipBorderProps { @@ -22,6 +32,29 @@ 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; +} + /** * @param serviceType Service type for which the label needs to be displayed * @param serviceTypeList List of available service types in Cloud Pulse @@ -118,3 +151,103 @@ 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, + }; + } + ); +}; 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.tsx b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx index a2d4574955e..85a3c07f199 100644 --- a/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx +++ b/packages/manager/src/features/CloudPulse/Dashboard/CloudPulseDashboardLanding.tsx @@ -2,6 +2,8 @@ import { Box, Paper } from '@linode/ui'; import { Grid } from '@mui/material'; import * as React from 'react'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; + import { GlobalFilters } from '../Overview/GlobalFilters'; import { CloudPulseAppliedFilterRenderer } from '../shared/CloudPulseAppliedFilterRenderer'; import { defaultTimeDuration } from '../Utils/CloudPulseDateTimePickerUtils'; @@ -75,30 +77,33 @@ export const CloudPulseDashboardLanding = () => { [] ); return ( - - - - - - {dashboard?.service_type && showAppliedFilters && ( - + + + + + + - )} - - + {dashboard?.service_type && showAppliedFilters && ( + + )} + + + + - -
+ ); }; 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/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/Widget/CloudPulseWidget.tsx b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx index 482f633ecd3..dc66cd84b2e 100644 --- a/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx +++ b/packages/manager/src/features/CloudPulse/Widget/CloudPulseWidget.tsx @@ -271,18 +271,25 @@ export const CloudPulseWidget = (props: CloudPulseWidgetProperties) => { const tickFormat = hours <= 24 ? 'hh:mm a' : 'LLL dd'; return ( - + {convertStringToCamelCasesWithSpaces(widget.label)} ( @@ -290,12 +297,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/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 ( - { }) ); - // 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..d7cb0da663c 100644 --- a/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx +++ b/packages/manager/src/features/Databases/DatabaseCreate/DatabaseCreate.tsx @@ -1,13 +1,21 @@ -import { BetaChip, CircleProgress, Divider, Notice, Paper } from '@linode/ui'; +import { + BetaChip, + CircleProgress, + Divider, + ErrorState, + Notice, + Paper, +} from '@linode/ui'; +import { formatStorageUnits } 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'; @@ -30,11 +38,14 @@ import { 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 +62,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/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..be2e0413b53 --- /dev/null +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseAdvancedConfiguration/DatabaseAdvancedConfigurationDrawer.tsx @@ -0,0 +1,69 @@ +import { Divider, Notice, Typography } from '@linode/ui'; +import React, { useState } from 'react'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Drawer } from 'src/components/Drawer'; +import { Link } from 'src/components/Link'; + +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/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/DatabaseResizeCurrentConfiguration.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResizeCurrentConfiguration.tsx index 68a715688bd..47e1d1fab65 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResizeCurrentConfiguration.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseResize/DatabaseResizeCurrentConfiguration.tsx @@ -1,13 +1,12 @@ -import { Box, CircleProgress, TooltipIcon } from '@linode/ui'; +import { Box, CircleProgress, ErrorState, TooltipIcon } from '@linode/ui'; +import { 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'; diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsSuspendClusterDialog.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsSuspendClusterDialog.tsx index 8165093c13d..f9c676c36ac 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsSuspendClusterDialog.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSettings/DatabaseSettingsSuspendClusterDialog.tsx @@ -57,9 +57,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} = (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.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx index 8420ffc1c3d..4f3cdc25d9f 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseSummary/DatabaseSummaryClusterConfiguration.tsx @@ -1,5 +1,6 @@ import { TooltipIcon, Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2/Grid2'; +import { formatStorageUnits } from '@linode/utilities'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -13,7 +14,6 @@ import { DatabaseEngineVersion } from 'src/features/Databases/DatabaseEngineVers 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'; @@ -73,35 +73,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.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..bb639ecc62f 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,5 @@ import { Box, TooltipIcon, Typography } from '@linode/ui'; +import { formatStorageUnits } from '@linode/utilities'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; @@ -7,7 +8,6 @@ import { DatabaseEngineVersion } from 'src/features/Databases/DatabaseEngineVers 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'; 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..e1bdd168ece 100644 --- a/packages/manager/src/features/Databases/DatabaseDetail/index.tsx +++ b/packages/manager/src/features/Databases/DatabaseDetail/index.tsx @@ -1,11 +1,10 @@ -import { CircleProgress, Notice } from '@linode/ui'; +import { CircleProgress, ErrorState, Notice } from '@linode/ui'; 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'; @@ -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/DatabaseLanding/DatabaseLanding.test.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx index a7230c71e9f..1fec33de820 100644 --- a/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx +++ b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseLanding.test.tsx @@ -1,3 +1,4 @@ +import { capitalize } from '@linode/utilities'; import { screen, within } from '@testing-library/react'; import { fireEvent } from '@testing-library/react'; import { waitForElementToBeRemoved } from '@testing-library/react'; @@ -9,7 +10,6 @@ import DatabaseLanding from 'src/features/Databases/DatabaseLanding/DatabaseLand import DatabaseRow from 'src/features/Databases/DatabaseLanding/DatabaseRow'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { HttpResponse, http, server } from 'src/mocks/testServer'; -import { capitalize } from 'src/utilities/capitalize'; import { formatDate } from 'src/utilities/formatDate'; import { mockMatchMedia, 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/DatabaseRow.tsx b/packages/manager/src/features/Databases/DatabaseLanding/DatabaseRow.tsx index 177df043eae..abfb789168b 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'; @@ -14,7 +15,6 @@ import { useProfile } from 'src/queries/profile/profile'; import { useRegionsQuery } from 'src/queries/regions/regions'; import { isWithinDays, parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; -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..2946366fdd0 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, @@ -559,3 +560,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..50643de2d08 100644 --- a/packages/manager/src/features/Databases/utilities.ts +++ b/packages/manager/src/features/Databases/utilities.ts @@ -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/CreateDomain/CreateDomain.tsx b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx index bc7691d0049..708ec79f305 100644 --- a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx +++ b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx @@ -10,7 +10,7 @@ import { } from '@linode/ui'; import { createDomainSchema } from '@linode/validation/lib/domains.schema'; import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { useNavigate } from '@tanstack/react-router'; import { useFormik } from 'formik'; import * as React from 'react'; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx index b357736aa2f..16f6bd05d1c 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainDetail.tsx @@ -1,10 +1,15 @@ -import { CircleProgress, Notice, Paper, Typography } from '@linode/ui'; +import { + CircleProgress, + ErrorState, + Notice, + Paper, + Typography, +} from '@linode/ui'; import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { useLocation, useNavigate, useParams } from '@tanstack/react-router'; import * as React from 'react'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; import { TagCell } from 'src/components/TagCell/TagCell'; import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; @@ -118,7 +123,7 @@ export const DomainDetail = () => { )} - + { updateRecords={refetchRecords} /> - + Tags diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts index 3ee8534e082..0f077bed392 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.styles.ts @@ -1,5 +1,5 @@ import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { TableCell } from 'src/components/TableCell'; diff --git a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx index eb5e4b9d48f..6971a568175 100644 --- a/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/DomainRecords/DomainRecords.tsx @@ -1,6 +1,6 @@ import { deleteDomainRecord as _deleteDomainRecord } from '@linode/api-v4/lib/domains'; import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; diff --git a/packages/manager/src/features/Domains/DomainDetail/index.tsx b/packages/manager/src/features/Domains/DomainDetail/index.tsx index 6eaa1b60106..10f5274d43e 100644 --- a/packages/manager/src/features/Domains/DomainDetail/index.tsx +++ b/packages/manager/src/features/Domains/DomainDetail/index.tsx @@ -1,8 +1,7 @@ -import { CircleProgress } from '@linode/ui'; +import { CircleProgress, ErrorState } from '@linode/ui'; import { useParams } from '@tanstack/react-router'; import * as React from 'react'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { NotFound } from 'src/components/NotFound'; import { useDomainQuery } from 'src/queries/domains'; diff --git a/packages/manager/src/features/Domains/DomainsLanding.tsx b/packages/manager/src/features/Domains/DomainsLanding.tsx index 81e8ac001b5..43a8ca06e61 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'; 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/EntityTransfers/EntityTransfersCreate/EntityTransferCreate.styles.ts b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransferCreate.styles.ts index aea30496629..8c27bf404ee 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransferCreate.styles.ts +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransferCreate.styles.ts @@ -1,6 +1,6 @@ import { Notice } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; export const StyledNotice = styled(Notice, { label: 'StyledNotice', diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx index 8801a7e6f63..4302bb60a99 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersCreate/EntityTransfersCreate.tsx @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { useQueryClient } from '@tanstack/react-query'; import { createLazyRoute } from '@tanstack/react-router'; import { curry } from 'ramda'; @@ -101,7 +101,13 @@ export const EntityTransfersCreate = () => { /> ) : null} - + { selectedLinodes={state.linodes} /> - + handleCreateTransfer(payload, queryClient) diff --git a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx index 9e69baf3102..049db5dbeb6 100644 --- a/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx +++ b/packages/manager/src/features/EntityTransfers/EntityTransfersLanding/ConfirmTransferDialog.tsx @@ -1,11 +1,11 @@ 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 } 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, @@ -13,7 +13,6 @@ import { } from 'src/queries/entityTransfers'; import { useProfile } from 'src/queries/profile/profile'; import { sendEntityTransferReceiveEvent } from 'src/utilities/analytics/customEventAnalytics'; -import { capitalize } from 'src/utilities/capitalize'; import { parseAPIDate } from 'src/utilities/date'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; import { formatDate } from 'src/utilities/formatDate'; 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..9a6e5eb7fe8 100644 --- a/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx +++ b/packages/manager/src/features/EntityTransfers/RenderTransferRow.tsx @@ -1,11 +1,11 @@ import { StyledLinkButton } from '@linode/ui'; +import { capitalize } 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 { 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/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..e2ce2d7329a 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 { 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..99607a9e36b 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx @@ -1,8 +1,8 @@ import { 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'; @@ -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(); 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..bb1b0df38b6 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx @@ -1,8 +1,8 @@ import { 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'; @@ -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); 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..60f9ffa7c71 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx @@ -1,15 +1,15 @@ import { Button, Notice, Typography } from '@linode/ui'; -import { useTheme } from '@mui/material/styles'; +import Grid from '@mui/material/Grid2'; import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import { 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 +22,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 +97,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..c44b59a22e6 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx @@ -4,27 +4,24 @@ import { Link } from 'react-router-dom'; 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..b57b40568d6 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('src/queries/firewalls', async () => { + const actual = await vi.importActual('src/queries/firewalls'); + 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..69745f8baf8 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx @@ -1,7 +1,5 @@ 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 +7,40 @@ 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 { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; 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/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..6d08b9775f0 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 { 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 { formValueToIPs, diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index 10e2549a106..ee159ac0d72 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -7,6 +7,7 @@ import { TextField, Typography, } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -18,7 +19,6 @@ import { portPresets, protocolOptions, } from 'src/features/Firewalls/shared'; -import { capitalize } from 'src/utilities/capitalize'; import { ipFieldPlaceholder } from 'src/utilities/ipUtils'; import { enforceIPMasks } from './FirewallRuleDrawer.utils'; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx index d827cc3e86d..f7e966e6447 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleTable.tsx @@ -15,13 +15,14 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { Box, Typography } from '@linode/ui'; +import { Autocomplete } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import { prop, uniqBy } from 'ramda'; import * as React from 'react'; import Undo from 'src/assets/icons/undo.svg'; -import { Autocomplete } from '@linode/ui'; import { Hidden } from 'src/components/Hidden'; import { MaskableText } from 'src/components/MaskableText/MaskableText'; import { Table } from 'src/components/Table'; @@ -35,7 +36,6 @@ import { generateRuleLabel, predefinedFirewallFromRule as ruleToPredefinedFirewall, } from 'src/features/Firewalls/shared'; -import { capitalize } from 'src/utilities/capitalize'; import { CustomKeyboardSensor } from 'src/utilities/CustomKeyboardSensor'; import { FirewallRuleActionMenu } from './FirewallRuleActionMenu'; @@ -78,11 +78,11 @@ interface RuleRow { // ============================================================================= interface RowActionHandlers { - triggerCloneFirewallRule: (idx: number) => void; - triggerDeleteFirewallRule: (idx: number) => void; - triggerOpenRuleDrawerForEditing: (idx: number) => void; - triggerReorder: (startIdx: number, endIdx: number) => void; - triggerUndo: (idx: number) => void; + handleCloneFirewallRule: (idx: number) => void; + handleDeleteFirewallRule: (idx: number) => void; + handleOpenRuleDrawerForEditing: (idx: number) => void; + handleReorder: (startIdx: number, endIdx: number) => void; + handleUndo: (idx: number) => void; } interface FirewallRuleTableProps extends RowActionHandlers { @@ -101,15 +101,15 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { const { category, disabled, + handleCloneFirewallRule, + handleDeleteFirewallRule, + handleOpenRuleDrawerForEditing, handlePolicyChange, + handleReorder, + handleUndo, openRuleDrawer, policy, rulesWithStatus, - triggerCloneFirewallRule, - triggerDeleteFirewallRule, - triggerOpenRuleDrawerForEditing, - triggerReorder, - triggerUndo, } = props; const theme = useTheme(); @@ -141,7 +141,7 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { if (active && over && active.id !== over.id) { const sourceIndex = getRowDataIndex(Number(active.id)); const destinationIndex = getRowDataIndex(Number(over.id)); - triggerReorder(sourceIndex, destinationIndex); + handleReorder(sourceIndex, destinationIndex); } // Remove focus from the initial position when the drag ends. @@ -238,16 +238,16 @@ export const FirewallRuleTable = (props: FirewallRuleTableProps) => { aria-label={ thisRuleRow.label ?? `firewall rule ${thisRuleRow.id}` } - triggerOpenRuleDrawerForEditing={ - triggerOpenRuleDrawerForEditing + handleOpenRuleDrawerForEditing={ + handleOpenRuleDrawerForEditing } aria-roledescription={screenReaderMessage} aria-selected={false} disabled={disabled} + handleCloneFirewallRule={handleCloneFirewallRule} + handleDeleteFirewallRule={handleDeleteFirewallRule} + handleUndo={handleUndo} key={thisRuleRow.id} - triggerCloneFirewallRule={triggerCloneFirewallRule} - triggerDeleteFirewallRule={triggerDeleteFirewallRule} - triggerUndo={triggerUndo} {...thisRuleRow} id={thisRuleRow.id} /> @@ -278,10 +278,10 @@ interface RowActionHandlersWithDisabled export interface FirewallRuleTableRowProps extends RuleRow { disabled: RowActionHandlersWithDisabled['disabled']; - triggerCloneFirewallRule: RowActionHandlersWithDisabled['triggerCloneFirewallRule']; - triggerDeleteFirewallRule: RowActionHandlersWithDisabled['triggerDeleteFirewallRule']; - triggerOpenRuleDrawerForEditing: RowActionHandlersWithDisabled['triggerOpenRuleDrawerForEditing']; - triggerUndo: RowActionHandlersWithDisabled['triggerUndo']; + handleCloneFirewallRule: RowActionHandlersWithDisabled['handleCloneFirewallRule']; + handleDeleteFirewallRule: RowActionHandlersWithDisabled['handleDeleteFirewallRule']; + handleOpenRuleDrawerForEditing: RowActionHandlersWithDisabled['handleOpenRuleDrawerForEditing']; + handleUndo: RowActionHandlersWithDisabled['handleUndo']; } const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { @@ -290,6 +290,10 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { addresses, disabled, errors, + handleCloneFirewallRule, + handleDeleteFirewallRule, + handleOpenRuleDrawerForEditing, + handleUndo, id, index, label, @@ -297,18 +301,14 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { ports, protocol, status, - triggerCloneFirewallRule, - triggerDeleteFirewallRule, - triggerOpenRuleDrawerForEditing, - triggerUndo, } = props; const actionMenuProps = { disabled: status === 'PENDING_DELETION' || disabled, + handleCloneFirewallRule, + handleDeleteFirewallRule, + handleOpenRuleDrawerForEditing, idx: index, - triggerCloneFirewallRule, - triggerDeleteFirewallRule, - triggerOpenRuleDrawerForEditing, }; const theme = useTheme(); @@ -363,7 +363,7 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { {label || ( triggerOpenRuleDrawerForEditing(index)} + onClick={() => handleOpenRuleDrawerForEditing(index)} > Add a label @@ -395,7 +395,7 @@ const FirewallRuleTableRow = React.memo((props: FirewallRuleTableRowProps) => { triggerUndo(index)} + onClick={() => handleUndo(index)} status={status} > diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx index c773be919e3..f71cbf8f854 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding.tsx @@ -1,11 +1,13 @@ import { Notice, Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; import { useQueryClient } from '@tanstack/react-query'; +import { useBlocker, useLocation, useNavigate } from '@tanstack/react-router'; import { useSnackbar } from 'notistack'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; +// eslint-disable-next-line no-restricted-imports import { Prompt } from 'src/components/Prompt/Prompt'; import { useAllFirewallDevicesQuery, @@ -44,7 +46,6 @@ interface Props { interface Drawer { category: Category; - isOpen: boolean; mode: FirewallRuleDrawerMode; ruleIdx?: number; } @@ -58,7 +59,8 @@ export const FirewallRulesLanding = React.memo((props: Props) => { ); const { data: devices } = useAllFirewallDevicesQuery(firewallID); const queryClient = useQueryClient(); - + const navigate = useNavigate(); + const location = useLocation(); const { enqueueSnackbar } = useSnackbar(); /** @@ -87,7 +89,6 @@ export const FirewallRulesLanding = React.memo((props: Props) => { */ const [ruleDrawer, setRuleDrawer] = React.useState({ category: 'inbound', - isOpen: false, mode: 'create', }); const [submitting, setSubmitting] = React.useState(false); @@ -104,15 +105,32 @@ export const FirewallRulesLanding = React.memo((props: Props) => { category: Category, mode: FirewallRuleDrawerMode, idx?: number - ) => + ) => { setRuleDrawer({ category, - isOpen: true, mode, ruleIdx: idx, }); + navigate({ + params: { id: String(firewallID), ruleId: String(idx) }, + to: + category === 'inbound' && mode === 'create' + ? '/firewalls/$id/rules/add/inbound' + : category === 'inbound' && mode === 'edit' + ? `/firewalls/$id/rules/edit/inbound/$ruleId` + : category === 'outbound' && mode === 'create' + ? '/firewalls/$id/rules/add/outbound' + : `/firewalls/$id/rules/edit/outbound/$ruleId`, + }); + }; - const closeRuleDrawer = () => setRuleDrawer({ ...ruleDrawer, isOpen: false }); + const closeRuleDrawer = () => { + setRuleDrawer({ ...ruleDrawer }); + navigate({ + params: { id: String(firewallID) }, + to: '/firewalls/$id/rules', + }); + }; /** * Rule Editor state hand handlers @@ -267,6 +285,41 @@ export const FirewallRulesLanding = React.memo((props: Props) => { [inboundState, outboundState, policy, rules] ); + const { proceed, reset, status } = useBlocker({ + enableBeforeUnload: hasUnsavedChanges, + shouldBlockFn: ({ next }) => { + // Only block if there are unsaved changes + if (!hasUnsavedChanges) { + return false; + } + + // Don't block navigation to these specific routes, since they are part of the current form + const isNavigatingToAllowedRoute = + next.routeId === '/firewalls/$id/rules' || + next.routeId === '/firewalls/$id/rules/add/inbound' || + next.routeId === '/firewalls/$id/rules/add/outbound' || + next.routeId === '/firewalls/$id/rules/edit/inbound/$ruleId' || + next.routeId === '/firewalls/$id/rules/edit/outbound/$ruleId'; + + return !isNavigatingToAllowedRoute; + }, + withResolver: true, + }); + + // Create a combined handler for proceeding with navigation + const handleProceedNavigation = React.useCallback(() => { + if (status === 'blocked' && proceed) { + proceed(); + } + }, [status, proceed]); + + // Create a combined handler for canceling navigation + const handleCancelNavigation = React.useCallback(() => { + if (status === 'blocked' && reset) { + reset(); + } + }, [status, reset]); + const inboundRules = React.useMemo(() => editorStateToRules(inboundState), [ inboundState, ]); @@ -285,34 +338,47 @@ export const FirewallRulesLanding = React.memo((props: Props) => { return ( <> + {/* + This Prompt eventually can be removed once react-router is fully deprecated + It is here only to preserve the behavior of non-Tanstack routes + */} - {({ handleCancel, handleConfirm, isModalOpen }) => { - return ( - ( - - )} - onClose={handleCancel} - open={isModalOpen} - title="Discard Firewall changes?" - > - - The changes you made to this Firewall haven’t been - applied. If you navigate away from this page, your changes will - be discarded. - - - ); - }} + {({ handleCancel, handleConfirm, isModalOpen }) => ( + ( + { + handleCancelNavigation(); + handleCancel(); + }, + }} + secondaryButtonProps={{ + buttonType: 'secondary', + color: 'error', + label: 'Leave and discard changes', + onClick: () => { + handleProceedNavigation(); + handleConfirm(); + }, + }} + /> + )} + onClose={() => { + handleCancelNavigation(); + handleCancel(); + }} + open={status === 'blocked' || isModalOpen} + title="Discard Firewall changes?" + > + + The changes you made to this Firewall haven’t been applied. + If you navigate away from this page, your changes will be + discarded. + + + )} {disabled ? ( @@ -324,56 +390,59 @@ export const FirewallRulesLanding = React.memo((props: Props) => { variant="error" /> ) : null} - {generalErrors?.length === 1 && ( )} - + handleCloneFirewallRule={(idx: number) => handleCloneRule('inbound', idx) } - triggerOpenRuleDrawerForEditing={(idx: number) => + handleOpenRuleDrawerForEditing={(idx: number) => openRuleDrawer('inbound', 'edit', idx) } - triggerReorder={(startIdx: number, endIdx: number) => + handleReorder={(startIdx: number, endIdx: number) => handleReorder('inbound', startIdx, endIdx) } category="inbound" disabled={disabled} + handleDeleteFirewallRule={(idx) => handleDeleteRule('inbound', idx)} handlePolicyChange={handlePolicyChange} + handleUndo={(idx) => handleUndo('inbound', idx)} openRuleDrawer={openRuleDrawer} policy={policy.inbound} rulesWithStatus={inboundRules} - triggerDeleteFirewallRule={(idx) => handleDeleteRule('inbound', idx)} - triggerUndo={(idx) => handleUndo('inbound', idx)} /> + handleCloneFirewallRule={(idx: number) => handleCloneRule('outbound', idx) } - triggerOpenRuleDrawerForEditing={(idx: number) => + handleOpenRuleDrawerForEditing={(idx: number) => openRuleDrawer('outbound', 'edit', idx) } - triggerReorder={(startIdx: number, endIdx: number) => + handleReorder={(startIdx: number, endIdx: number) => handleReorder('outbound', startIdx, endIdx) } category="outbound" disabled={disabled} + handleDeleteFirewallRule={(idx) => handleDeleteRule('outbound', idx)} handlePolicyChange={handlePolicyChange} + handleUndo={(idx) => handleUndo('outbound', idx)} openRuleDrawer={openRuleDrawer} policy={policy.outbound} rulesWithStatus={outboundRules} - triggerDeleteFirewallRule={(idx) => handleDeleteRule('outbound', idx)} - triggerUndo={(idx) => handleUndo('outbound', idx)} /> { onClick: () => setDiscardChangesModalOpen(true), }} /> - { setDiscardChangesModalOpen(false); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx index 85a97b47c54..f07b9e2d668 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx @@ -1,23 +1,22 @@ -import { CircleProgress } from '@linode/ui'; -import { createLazyRoute } from '@tanstack/react-router'; +import { CircleProgress, ErrorState } from '@linode/ui'; +import { useParams } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory, useParams } from 'react-router-dom'; import { AkamaiBanner } from 'src/components/AkamaiBanner/AkamaiBanner'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { GenerateFirewallDialog } from 'src/components/GenerateFirewallDialog/GenerateFirewallDialog'; import { LandingHeader } from 'src/components/LandingHeader'; import { LinkButton } from 'src/components/LinkButton'; import { NotFound } from 'src/components/NotFound'; 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 { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import { useFlags } from 'src/hooks/useFlags'; import { useSecureVMNoticesEnabled } from 'src/hooks/useSecureVMNoticesEnabled'; -import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; +import { useTabs } from 'src/hooks/useTabs'; import { useFirewallQuery, useMutateFirewall } from 'src/queries/firewalls'; +import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; import { useGrants, useProfile } from 'src/queries/profile/profile'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; @@ -36,8 +35,9 @@ const FirewallDeviceLanding = React.lazy(() => ); export const FirewallDetail = () => { - const { id, tab } = useParams<{ id: string; tab?: string }>(); - const history = useHistory(); + const { id } = useParams({ + strict: false, + }); const { data: profile } = useProfile(); const { data: grants } = useGrants(); const { secureVMNoticesEnabled } = useSecureVMNoticesEnabled(); @@ -69,22 +69,20 @@ export const FirewallDetail = () => { { linodeCount: 0, nodebalancerCount: 0 } ) || { linodeCount: 0, nodebalancerCount: 0 }; - const tabs = [ + const { handleTabChange, tabIndex, tabs } = useTabs([ { - routeName: `/firewalls/${id}/rules`, title: 'Rules', + to: `/firewalls/$id/rules`, }, { - routeName: `/firewalls/${id}/linodes`, title: `Linodes (${linodeCount})`, + to: `/firewalls/$id/linodes`, }, { - routeName: `/firewalls/${id}/nodebalancers`, title: `NodeBalancers (${nodebalancerCount})`, + to: `/firewalls/$id/nodebalancers`, }, - ]; - - const tabIndex = tab ? tabs.findIndex((t) => t.routeName.endsWith(tab)) : -1; + ]); const { data: firewall, error, isLoading } = useFirewallQuery(firewallId); @@ -151,12 +149,8 @@ export const FirewallDetail = () => { {...secureVMFirewallBanner.firewallDetails} /> )} - history.push(tabs[i].routeName)} - > - - + + { ); }; - -export const firewallDetailLazyRoute = createLazyRoute('/firewalls/$id')({ - component: FirewallDetail, -}); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.test.tsx index 82fc5b85e66..6b3d49e340d 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.test.tsx @@ -54,4 +54,31 @@ describe('Create Firewall Drawer', () => { ); expect(error).toBeInTheDocument(); }); + + it('shows custom firewall radio group if Linode Interfaces flag is enabled and can toggle radio group', async () => { + const { getByLabelText, getByTestId } = renderWithTheme( + , + { + flags: { linodeInterfaces: { enabled: true } }, + } + ); + + expect(getByTestId('create-firewall-from')).toBeVisible(); + + const templateRadio = getByLabelText('From a Template'); + await userEvent.click(templateRadio); + expect(getByLabelText('Firewall Template')).toBeVisible(); + }); + + it('should not show the custom firewall radio group if Linode Interfaces flag is not enabled', () => { + const { queryByLabelText, queryByTestId } = renderWithTheme( + , + { + flags: { linodeInterfaces: { enabled: false } }, + } + ); + + expect(queryByTestId('create-firewall-from')).not.toBeInTheDocument(); + expect(queryByLabelText('Firewall Template')).not.toBeInTheDocument(); + }); }); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx index 1465f91002b..060b4ef368d 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx @@ -1,60 +1,37 @@ -/* eslint-disable jsx-a11y/anchor-is-valid */ import { - Box, FormControlLabel, Notice, Radio, RadioGroup, - TextField, Typography, + omitProps, } from '@linode/ui'; -import { CreateFirewallSchema } from '@linode/validation/lib/firewalls.schema'; -import { useFormik } from 'formik'; +import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; +// eslint-disable-next-line no-restricted-imports import { useLocation } from 'react-router-dom'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { ErrorMessage } from 'src/components/ErrorMessage'; -import { Link } from 'src/components/Link'; -import { FIREWALL_LIMITS_CONSIDERATIONS_LINK } from 'src/constants'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; -import { NodeBalancerSelect } from 'src/features/NodeBalancers/NodeBalancerSelect'; +import { createFirewallFromTemplate } from 'src/components/GenerateFirewallDialog/useCreateFirewallFromTemplate'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; -import { useAllFirewallsQuery, useCreateFirewall } from 'src/queries/firewalls'; -import { useGrants } from 'src/queries/profile/profile'; -import { - sendLinodeCreateFormInputEvent, - sendLinodeCreateFormStepEvent, -} from 'src/utilities/analytics/formEventAnalytics'; -import { getErrorMap } from 'src/utilities/errorUtils'; -import { - handleFieldErrors, - handleGeneralErrors, -} from 'src/utilities/formikErrorUtils'; -import { getEntityIdsByPermission } from 'src/utilities/grants'; +import { useCreateFirewall } from 'src/queries/firewalls'; +import { sendLinodeCreateFormStepEvent } from 'src/utilities/analytics/formEventAnalytics'; +import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; -import { - LINODE_CREATE_FLOW_TEXT, - NODEBALANCER_CREATE_FLOW_TEXT, -} from './constants'; +import { CustomFirewallFields } from './CustomFirewallFields'; +import { createFirewallResolver } from './formUtilities'; +import { TemplateFirewallFields } from './TemplateFirewallFields'; -import type { - CreateFirewallPayload, - Firewall, - FirewallDeviceEntityType, - Linode, - NodeBalancer, -} from '@linode/api-v4'; +import type { CreateFirewallFormValues } from './formUtilities'; +import type { Firewall, FirewallDeviceEntityType } from '@linode/api-v4'; import type { LinodeCreateQueryParams } from 'src/features/Linodes/types'; import type { LinodeCreateFormEventOptions } from 'src/utilities/analytics/types'; -export const READ_ONLY_DEVICES_HIDDEN_MESSAGE = - 'Only services you have permission to modify are shown.'; -const NODEBALANCER_HELPER_TEXT = `Only the firewall's inbound rules apply to NodeBalancers.`; - export interface CreateFirewallDrawerProps { createFlow: FirewallDeviceEntityType | undefined; onClose: () => void; @@ -62,7 +39,10 @@ export interface CreateFirewallDrawerProps { open: boolean; } -const initialValues: CreateFirewallPayload = { +const createFirewallText = 'Create Firewall'; + +const initialValues: CreateFirewallFormValues = { + createFirewallFrom: 'custom', devices: { linodes: [], nodebalancers: [], @@ -72,6 +52,7 @@ const initialValues: CreateFirewallPayload = { inbound_policy: 'DROP', outbound_policy: 'ACCEPT', }, + templateSlug: undefined, }; export const CreateFirewallDrawer = React.memo( @@ -79,9 +60,9 @@ export const CreateFirewallDrawer = React.memo( // TODO: NBFW - We'll eventually want to check the read_write firewall grant here too, but it doesn't exist yet. const { createFlow, onClose, onFirewallCreated, open } = props; const { _hasGrant, _isRestrictedUser } = useAccountManagement(); - const { data: grants } = useGrants(); - const { mutateAsync } = useCreateFirewall(); - const { data } = useAllFirewallsQuery(open); + const { mutateAsync: createFirewall } = useCreateFirewall(); + const queryClient = useQueryClient(); + const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); const { enqueueSnackbar } = useSnackbar(); @@ -93,321 +74,159 @@ export const CreateFirewallDrawer = React.memo( const firewallFormEventOptions: LinodeCreateFormEventOptions = { createType: queryParams.type ?? 'OS', - headerName: 'Create Firewall', + headerName: createFirewallText, interaction: 'click', label: '', }; + const form = useForm({ + defaultValues: initialValues, + mode: 'onBlur', + resolver: createFirewallResolver(), + values: initialValues, + }); + const { - errors, - handleBlur, - handleChange, + clearErrors, + control, + formState: { errors, isSubmitting }, handleSubmit, - isSubmitting, - resetForm, - setFieldValue, - status, - values, - } = useFormik({ - initialValues, - onSubmit( - values: CreateFirewallPayload, - { setErrors, setStatus, setSubmitting } - ) { - // Clear drawer error state - setStatus(undefined); - setErrors({}); - const payload = { ...values }; - - if (payload.label === '') { - payload.label = undefined; + reset, + setError, + watch, + } = form; + + const createFirewallFrom = watch('createFirewallFrom'); + + const onSubmit = async (values: CreateFirewallFormValues) => { + const payload = omitProps(values, ['templateSlug', 'createFirewallFrom']); + const slug = values.templateSlug; + try { + const firewall = + createFirewallFrom === 'template' && slug + ? await createFirewallFromTemplate({ + createFirewall, + queryClient, + templateSlug: slug, + }) + : await createFirewall(payload); + enqueueSnackbar(`Firewall ${values.label} successfully created`, { + variant: 'success', + }); + + if (onFirewallCreated) { + onFirewallCreated(firewall); } - - if ( - Array.isArray(payload.rules.inbound) && - payload.rules.inbound.length === 0 - ) { - payload.rules.inbound = undefined; + onClose(); + // Fire analytics form submit upon successful firewall creation from Linode Create flow. + if (isFromLinodeCreate) { + sendLinodeCreateFormStepEvent({ + ...firewallFormEventOptions, + label: createFirewallText, + }); } - - if ( - Array.isArray(payload.rules.outbound) && - payload.rules.outbound.length === 0 - ) { - payload.rules.outbound = undefined; + } catch (errors) { + for (const error of errors) { + if (error?.field === 'rules') { + setError('root', { message: error.reason }); + } else { + setError(error?.field ?? 'root', { message: error.reason }); + } } - - mutateAsync(payload) - .then((response) => { - setSubmitting(false); - enqueueSnackbar(`Firewall ${payload.label} successfully created`, { - variant: 'success', - }); - - if (onFirewallCreated) { - onFirewallCreated(response); - } - onClose(); - - // Fire analytics form submit upon successful firewall creation from Linode Create flow. - if (isFromLinodeCreate) { - sendLinodeCreateFormStepEvent({ - ...firewallFormEventOptions, - label: 'Create Firewall', - }); - } - }) - .catch((err) => { - const mapErrorToStatus = () => - setStatus({ generalError: getErrorMap([], err).none }); - - setSubmitting(false); - handleFieldErrors(setErrors, err); - handleGeneralErrors( - mapErrorToStatus, - err, - 'Error creating Firewall.' - ); - }); - }, - validateOnBlur: false, - validateOnChange: false, - validationSchema: CreateFirewallSchema, - }); - - const FirewallLabelText = `Assign services to the Firewall`; - const FirewallHelperText = `Assign one or more services to this firewall. You can add services later if you want to customize your rules first.`; - - React.useEffect(() => { - if (open) { - resetForm(); } - }, [open, resetForm]); - - const handleInboundPolicyChange = React.useCallback( - (e: React.ChangeEvent, value: 'ACCEPT' | 'DROP') => { - setFieldValue('rules.inbound_policy', value); - }, - [setFieldValue] - ); - - const handleOutboundPolicyChange = React.useCallback( - (e: React.ChangeEvent, value: 'ACCEPT' | 'DROP') => { - setFieldValue('rules.outbound_policy', value); - }, - [setFieldValue] - ); + }; const userCannotAddFirewall = _isRestrictedUser && !_hasGrant('add_firewalls'); - // If a user is restricted, they can not add a read-only Linode to a firewall. - const readOnlyLinodeIds = _isRestrictedUser - ? getEntityIdsByPermission(grants, 'linode', 'read_only') - : []; - - // If a user is restricted, they can not add a read-only NodeBalancer to a firewall. - const readOnlyNodebalancerIds = _isRestrictedUser - ? getEntityIdsByPermission(grants, 'nodebalancer', 'read_only') - : []; - - const deviceSelectGuidance = - readOnlyLinodeIds.length > 0 || readOnlyNodebalancerIds.length > 0 - ? READ_ONLY_DEVICES_HIDDEN_MESSAGE - : undefined; - - const assignedServices = data?.map((firewall) => firewall.entities).flat(); - - const assignedLinodes = assignedServices?.filter( - (service) => service.type === 'linode' - ); - const assignedNodeBalancers = assignedServices?.filter( - (service) => service.type === 'nodebalancer' - ); - - const linodeOptionsFilter = (linode: Linode) => { - return ( - !readOnlyLinodeIds.includes(linode.id) && - !assignedLinodes?.some((service) => service.id === linode.id) - ); - }; - - const nodebalancerOptionsFilter = (nodebalancer: NodeBalancer) => { - return ( - !readOnlyNodebalancerIds.includes(nodebalancer.id) && - !assignedNodeBalancers?.some( - (service) => service.id === nodebalancer.id - ) - ); - }; - - const learnMoreLink = ( - - isFromLinodeCreate && - sendLinodeCreateFormInputEvent({ - ...firewallFormEventOptions, - label: 'Learn more', - subheaderName: 'Assign services to the Firewall', - }) - } - to={FIREWALL_LIMITS_CONSIDERATIONS_LINK} - > - Learn more - - ); - - const generalError = - status?.generalError || - // @ts-expect-error this form intentionally breaks Formik's error type - errors['rules.inbound'] || - // @ts-expect-error this form intentionally breaks Formik's error type - errors['rules.outbound'] || - errors.rules; - return ( - -
    - {userCannotAddFirewall ? ( - - ) : null} - {generalError && ( - - + reset()} + open={open} + title={createFirewallText} + > + + {userCannotAddFirewall ? ( + - - )} - - - - Default Inbound Policy - - - } - label="Accept" - value="ACCEPT" - /> - } label="Drop" value="DROP" /> - - - - Default Outbound Policy - - - } - label="Accept" - value="ACCEPT" + ) : null} + {errors.root?.message && ( + + + + )} + {isLinodeInterfacesEnabled && ( + <> + + Create + + ( + { + field.onChange(value); + clearErrors(); + }} + aria-label="Create custom firewall or from a template" + data-testid="create-firewall-from" + row + value={field.value} + > + } + label="Custom Firewall" + value="custom" + /> + } + label="From a Template" + value="template" + /> + + )} + control={control} + name="createFirewallFrom" + /> + + )} + {createFirewallFrom === 'template' && isLinodeInterfacesEnabled ? ( + + ) : ( + + )} + - } label="Drop" value="DROP" /> - - - - ({ - margin: `${theme.spacing(2)} ${theme.spacing(0)}`, - })} - variant="h3" - > - {FirewallLabelText} - - - {FirewallHelperText} - {deviceSelectGuidance ? ` ${deviceSelectGuidance}` : null} - - ({ - margin: `${theme.spacing(2)} ${theme.spacing(0)}`, - })} - > - {NODEBALANCER_HELPER_TEXT} -
    - {learnMoreLink}. -
    -
    - { - setFieldValue( - 'devices.linodes', - linodes.map((linode) => linode.id) - ); - }} - // @ts-expect-error this form intentionally breaks Formik's error type - errorText={errors['devices.linodes']} - helperText={deviceSelectGuidance} - multiple - optionsFilter={linodeOptionsFilter} - value={values.devices?.linodes ?? null} - /> - { - setFieldValue( - 'devices.nodebalancers', - nodebalancers.map((nodebalancer) => nodebalancer.id) - ); - }} - // @ts-expect-error this form intentionally breaks Formik's error type - errorText={errors['devices.nodebalancers']} - helperText={deviceSelectGuidance} - multiple - optionsFilter={nodebalancerOptionsFilter} - value={values.devices?.nodebalancers ?? null} - /> - - -
    + +
    + ); } ); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.test.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.test.tsx new file mode 100644 index 00000000000..447ca4eadaf --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.test.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; + +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; + +import { CustomFirewallFields } from './CustomFirewallFields'; + +import type { LinodeCreateFormEventOptions } from 'src/utilities/analytics/types'; + +const props = { + createFlow: undefined, + firewallFormEventOptions: { + createType: 'OS', + headerName: 'Create Fireall', + interaction: 'click', + label: '', + } as LinodeCreateFormEventOptions, + isFromLinodeCreate: false, + open: true, + userCannotAddFirewall: false, +}; + +const formOptions = { + defaultValues: { + createFirewallFrom: 'custom', + devices: { + linodes: [], + nodebalancers: [], + }, + label: '', + rules: { + inbound_policy: 'DROP', + outbound_policy: 'ACCEPT', + }, + templateSlug: undefined, + }, +}; + +describe('CustomFirewallFields', () => { + it('renders the custom firewall fields', () => { + const { getByText } = renderWithThemeAndHookFormContext({ + component: , + useFormOptions: formOptions, + }); + + expect(getByText('Label')).toBeVisible(); + expect(getByText('Default Inbound Policy')).toBeVisible(); + expect(getByText('Default Outbound Policy')).toBeVisible(); + expect(getByText('Assign services to the Firewall')).toBeVisible(); + expect(getByText('Linodes')).toBeVisible(); + expect(getByText('NodeBalancers')).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx new file mode 100644 index 00000000000..379285093c1 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CustomFirewallFields.tsx @@ -0,0 +1,245 @@ +import { + Box, + FormControlLabel, + Radio, + RadioGroup, + TextField, + Typography, +} from '@linode/ui'; +import * as React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; +import { Link } from 'src/components/Link'; + +import { FIREWALL_LIMITS_CONSIDERATIONS_LINK } from 'src/constants'; +import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; +import { NodeBalancerSelect } from 'src/features/NodeBalancers/NodeBalancerSelect'; +import { useAccountManagement } from 'src/hooks/useAccountManagement'; +import { useAllFirewallsQuery } from 'src/queries/firewalls'; +import { useGrants } from 'src/queries/profile/profile'; +import { sendLinodeCreateFormInputEvent } from 'src/utilities/analytics/formEventAnalytics'; +import { getEntityIdsByPermission } from 'src/utilities/grants'; + +import { + FIREWALL_HELPER_TEXT, + FIREWALL_LABEL_TEXT, + LINODE_CREATE_FLOW_TEXT, + NODEBALANCER_CREATE_FLOW_TEXT, + NODEBALANCER_HELPER_TEXT, + READ_ONLY_DEVICES_HIDDEN_MESSAGE, +} from './constants'; + +import type { CreateFirewallFormValues } from './formUtilities'; +import type { + FirewallDeviceEntityType, + Linode, + NodeBalancer, +} from '@linode/api-v4'; +import type { LinodeCreateFormEventOptions } from 'src/utilities/analytics/types'; + +interface CustomFirewallProps { + createFlow: FirewallDeviceEntityType | undefined; + firewallFormEventOptions: LinodeCreateFormEventOptions; + isFromLinodeCreate: boolean; + open: boolean; + userCannotAddFirewall: boolean; +} +export const CustomFirewallFields = (props: CustomFirewallProps) => { + const { + createFlow, + firewallFormEventOptions, + isFromLinodeCreate, + open, + userCannotAddFirewall, + } = props; + const { control } = useFormContext(); + const { data } = useAllFirewallsQuery(open); + const { _isRestrictedUser } = useAccountManagement(); + const { data: grants } = useGrants(); + + // If a user is restricted, they can not add a read-only Linode to a firewall. + const readOnlyLinodeIds = _isRestrictedUser + ? getEntityIdsByPermission(grants, 'linode', 'read_only') + : []; + + // If a user is restricted, they can not add a read-only NodeBalancer to a firewall. + const readOnlyNodebalancerIds = _isRestrictedUser + ? getEntityIdsByPermission(grants, 'nodebalancer', 'read_only') + : []; + + const deviceSelectGuidance = + readOnlyLinodeIds.length > 0 || readOnlyNodebalancerIds.length > 0 + ? READ_ONLY_DEVICES_HIDDEN_MESSAGE + : undefined; + + const assignedServices = data?.map((firewall) => firewall.entities).flat(); + + const assignedLinodes = assignedServices?.filter( + (service) => service.type === 'linode' + ); + const assignedNodeBalancers = assignedServices?.filter( + (service) => service.type === 'nodebalancer' + ); + + const linodeOptionsFilter = (linode: Linode) => { + return ( + !readOnlyLinodeIds.includes(linode.id) && + !assignedLinodes?.some((service) => service.id === linode.id) + ); + }; + + const nodebalancerOptionsFilter = (nodebalancer: NodeBalancer) => { + return ( + !readOnlyNodebalancerIds.includes(nodebalancer.id) && + !assignedNodeBalancers?.some((service) => service.id === nodebalancer.id) + ); + }; + + const learnMoreLink = ( + + isFromLinodeCreate && + sendLinodeCreateFormInputEvent({ + ...firewallFormEventOptions, + label: 'Learn more', + subheaderName: 'Assign services to the Firewall', + }) + } + to={FIREWALL_LIMITS_CONSIDERATIONS_LINK} + > + Learn more + + ); + + return ( + <> + ( + + )} + control={control} + name="label" + /> + + Default Inbound Policy + + ( + + } + label="Accept" + value="ACCEPT" + /> + } label="Drop" value="DROP" /> + + )} + control={control} + name="rules.inbound_policy" + /> + + Default Outbound Policy + + ( + + } + label="Accept" + value="ACCEPT" + /> + } label="Drop" value="DROP" /> + + )} + control={control} + name="rules.outbound_policy" + /> + + ({ + margin: `${theme.spacing(2)} ${theme.spacing(0)}`, + })} + variant="h3" + > + {FIREWALL_LABEL_TEXT} + + + {FIREWALL_HELPER_TEXT} + {deviceSelectGuidance && ` ${deviceSelectGuidance}`} + + ({ + margin: `${theme.spacing(2)} ${theme.spacing(0)}`, + })} + > + {NODEBALANCER_HELPER_TEXT} +
    + {learnMoreLink}. +
    +
    + ( + { + field.onChange(linodes.map((linode) => linode.id)); + }} + errorText={fieldState.error?.message} + helperText={deviceSelectGuidance} + multiple + optionsFilter={linodeOptionsFilter} + value={field.value ?? null} + /> + )} + control={control} + name="devices.linodes" + /> + ( + { + field.onChange( + nodebalancers.map((nodebalancer) => nodebalancer.id) + ); + }} + errorText={fieldState.error?.message} + helperText={deviceSelectGuidance} + multiple + optionsFilter={nodebalancerOptionsFilter} + value={field.value ?? null} + /> + )} + control={control} + name="devices.nodebalancers" + /> + + ); +}; diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx index f992c271a6f..9b230f84360 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallDialog.tsx @@ -1,3 +1,4 @@ +import { capitalize } from '@linode/utilities'; import { useQueryClient } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -7,7 +8,6 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { useDeleteFirewall, useMutateFirewall } from 'src/queries/firewalls'; import { linodeQueries } from 'src/queries/linodes/linodes'; import { nodebalancerQueries } from 'src/queries/nodebalancers'; -import { capitalize } from 'src/utilities/capitalize'; import type { Firewall } from '@linode/api-v4'; diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx index 9b19844ac27..28ef64108ac 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx @@ -1,9 +1,8 @@ -import { Button, CircleProgress } from '@linode/ui'; -import { createLazyRoute } from '@tanstack/react-router'; +import { Button, CircleProgress, ErrorState } from '@linode/ui'; +import { useLocation, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; -import { useHistory, useLocation } from 'react-router-dom'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { GenerateFirewallDialog } from 'src/components/GenerateFirewallDialog/GenerateFirewallDialog'; import { Hidden } from 'src/components/Hidden'; import { LandingHeader } from 'src/components/LandingHeader'; @@ -30,13 +29,12 @@ import { FirewallRow } from './FirewallRow'; import type { ActionHandlers as FirewallHandlers } from './FirewallActionMenu'; import type { Mode } from './FirewallDialog'; -import { DocumentTitleSegment } from 'src/components/DocumentTitle'; const preferenceKey = 'firewalls'; const FirewallLanding = () => { + const navigate = useNavigate(); const location = useLocation(); - const history = useHistory(); const pagination = usePagination(1, preferenceKey); const { handleOrderChange, order, orderBy } = useOrder( { @@ -98,11 +96,11 @@ const FirewallLanding = () => { }; const onOpenCreateDrawer = () => { - history.replace('/firewalls/create'); + navigate({ to: '/firewalls/create' }); }; const onCloseCreateDrawer = () => { - history.replace('/firewalls'); + navigate({ to: '/firewalls' }); }; const handlers: FirewallHandlers = { @@ -193,7 +191,7 @@ const FirewallLanding = () => { Rules Services - +
    @@ -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.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..e52f47e9f10 --- /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.background.Neutral, + marginTop: theme.spacing(2), + padding: theme.spacing(2), + })} + data-testid="public-template-info" + > + {sharedTemplateRules} + + {sharedTemplatePolicies} + + ); +}; + +const templateRuleStyling = (theme: Theme) => ({ + backgroundColor: theme.tokens.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..3e265e35d32 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallLanding/TemplateFirewallFields.tsx @@ -0,0 +1,88 @@ +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 { useFirewallTemplatesQuery } from 'src/queries/firewalls'; + +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. + + )} + ( + = React.memo((props) => {
    {handleDelete ? (
    ) : ( <> - + IP Address @@ -544,7 +539,7 @@ export const IPTransfer = (props: Props) => { visibility: 'hidden', }, }} - xs={12} + size={12} > @@ -559,14 +554,20 @@ export const IPTransfer = (props: Props) => { with which to transfer IPs. ) : ( - + {Object.values(ips).map(ipRow)} )} )} - + lensPath([ip]), - mode: (ip: string) => lensPath([ip, 'mode']), - selectedIP: (ip: string) => lensPath([ip, 'selectedIP']), - selectedLinodeID: (ip: string) => lensPath([ip, 'selectedLinodeID']), - selectedLinodesIPs: (ip: string) => lensPath([ip, 'selectedLinodesIPs']), - sourceIP: (ip: string) => lensPath([ip, 'sourceIP']), - sourceIPsLinodeID: (ip: string) => lensPath([ip, 'sourceIPsLinodeID']), -}; - -const setMode = (ip: string, mode: Mode) => set(L.mode(ip), mode); - -const setSelectedIP = (ip: string, selectedIP: string) => - set(L.selectedIP(ip), selectedIP); - -const setSelectedLinodeID = (ip: string, selectedLinodeID: number | string) => - set(L.selectedLinodeID(ip), selectedLinodeID); - -const updateSelectedLinodesIPs = (ip: string, fn: (s: string[]) => string[]) => - over(L.selectedLinodesIPs(ip), fn); - -const updateSelectedIP = (ip: string, fn: (a: string) => string | undefined) => - over(L.selectedIP(ip), fn); - -const updateIPState = (ip: string, fn: (v: IPStates) => IPStates) => - over(L.ip(ip), fn); - -const isMoving = (mode: Mode) => mode === 'move'; - -const isSwapping = (mode: Mode) => mode === 'swap'; - -const isNone = (mode: Mode) => mode === 'none'; - const isNoneState = (state: Move | NoAction | Swap): state is NoAction => state.mode === 'none'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/AddFirewallForm.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/AddFirewallForm.tsx index 6ce1b41ba37..d65a7ddb014 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/AddFirewallForm.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/AddFirewallForm.tsx @@ -7,7 +7,7 @@ import { number, object } from 'yup'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Link } from 'src/components/Link'; -import { formattedTypes } from 'src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding'; +import { formattedTypes } from 'src/features/Firewalls/FirewallDetail/Devices/constants'; import { useAddFirewallDeviceMutation, useAllFirewallsQuery, diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx index ea130e2c723..6d0d59275d9 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx @@ -1,7 +1,7 @@ -import { Firewall, FirewallDevice } from '@linode/api-v4'; +import { capitalize } from '@linode/utilities'; import * as React from 'react'; -import { Link } from 'react-router-dom'; +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'; @@ -10,10 +10,11 @@ import { getRuleString, } from 'src/features/Firewalls/FirewallLanding/FirewallRow'; import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; -import { capitalize } from 'src/utilities/capitalize'; import { LinodeFirewallsActionMenu } from './LinodeFirewallsActionMenu'; +import type { Firewall, FirewallDevice } from '@linode/api-v4'; + interface LinodeFirewallsRowProps { firewall: Firewall; linodeID: number; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx index 868e6152c4c..acfbd875151 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeIPAddresses.tsx @@ -2,6 +2,7 @@ import { Box, Button, CircleProgress, + ErrorState, Paper, Stack, Typography, @@ -10,7 +11,6 @@ import { useMediaQuery, useTheme } from '@mui/material'; import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import OrderBy from 'src/components/OrderBy'; import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { Table } from 'src/components/Table'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceActionMenu.tsx new file mode 100644 index 00000000000..f5df5236376 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceActionMenu.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; + +import type { LinodeInterfaceType } from './utilities'; + +interface Props { + id: number; + type: LinodeInterfaceType; +} + +export const LinodeInterfaceActionMenu = (props: Props) => { + const { id, type } = props; + + const actions = [ + { onClick: () => alert(`Details ${id}`), title: 'Details' }, + { onClick: () => alert(`Edit ${id}`), title: 'Edit' }, + { onClick: () => alert(`Delete ${id}`), title: 'Delete' }, + ]; + + return ( + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceFirewall.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceFirewall.tsx new file mode 100644 index 00000000000..8daf2a5da93 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceFirewall.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import { Link } from 'src/components/Link'; +import { Skeleton } from 'src/components/Skeleton'; +import { useLinodeInterfaceFirewallsQuery } from 'src/queries/linodes/interfaces'; + +interface Props { + interfaceId: number; + linodeId: number; +} + +export const LinodeInterfaceFirewall = ({ interfaceId, linodeId }: Props) => { + const { data, error, isPending } = useLinodeInterfaceFirewallsQuery( + linodeId, + interfaceId + ); + + if (isPending) { + return ; + } + + if (error) { + return 'Unknown'; + } + + if (data.results === 0) { + return 'None'; + } + + const firewall = data.data[0]; + + return {firewall.label}; +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceTableRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceTableRow.tsx new file mode 100644 index 00000000000..934e0b00e26 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaceTableRow.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; + +import { LinodeInterfaceActionMenu } from './LinodeInterfaceActionMenu'; +import { LinodeInterfaceFirewall } from './LinodeInterfaceFirewall'; +import { getLinodeInterfaceType } from './utilities'; + +import type { LinodeInterface } from '@linode/api-v4'; + +interface Props extends LinodeInterface { + linodeId: number; +} + +export const LinodeInterfaceTableRow = (props: Props) => { + const { created, id, linodeId, mac_address, updated, version } = props; + + const type = getLinodeInterfaceType(props); + + return ( + + {id} + {type} + {mac_address} + {version} + + + + + + + + + + + + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaces.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaces.tsx new file mode 100644 index 00000000000..f54d7751a1f --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfaces.tsx @@ -0,0 +1,32 @@ +import { Box, Button, Paper, Stack, Typography } from '@linode/ui'; +import React from 'react'; + +import { LinodeInterfacesTable } from './LinodeInterfacesTable'; + +interface Props { + linodeId: number; +} + +export const LinodeInterfaces = ({ linodeId }: Props) => { + return ( + + + Network Interfaces + + + + + + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTable.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTable.tsx new file mode 100644 index 00000000000..80a214e5f4e --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTable.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; + +import { LinodeInterfacesTableContent } from './LinodeInterfacesTableContent'; + +interface Props { + linodeId: number; +} + +export const LinodeInterfacesTable = ({ linodeId }: Props) => { + return ( + + + + ID + Type + MAC Address + Version + Firewall + Updated + Created + + + + + + +
    + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTableContent.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTableContent.tsx new file mode 100644 index 00000000000..b78b16560e5 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/LinodeInterfacesTableContent.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TableRowError } from 'src/components/TableRowError/TableRowError'; +import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; +import { useLinodeInterfacesQuery } from 'src/queries/linodes/interfaces'; + +import { LinodeInterfaceTableRow } from './LinodeInterfaceTableRow'; + +interface Props { + linodeId: number; +} + +export const LinodeInterfacesTableContent = ({ linodeId }: Props) => { + const { data, error, isPending } = useLinodeInterfacesQuery(linodeId); + + if (isPending) { + return ; + } + + if (error) { + return ; + } + + if (data.interfaces.length === 0) { + return ( + + ); + } + + return data.interfaces.map((networkInterface) => ( + + )); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/utilities.test.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/utilities.test.ts new file mode 100644 index 00000000000..ee1c103dd77 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/utilities.test.ts @@ -0,0 +1,27 @@ +import { + linodeInterfaceFactoryPublic, + linodeInterfaceFactoryVPC, + linodeInterfaceFactoryVlan, +} from 'src/factories/linodeInterface'; + +import { getLinodeInterfaceType } from './utilities'; + +describe('getLinodeInterfaceType', () => { + it("returns 'public' if the given interface defines a public interface", () => { + const networkInterface = linodeInterfaceFactoryPublic.build(); + + expect(getLinodeInterfaceType(networkInterface)).toBe('public'); + }); + + it("returns 'vpc' if the given interface defines a VPC interface", () => { + const networkInterface = linodeInterfaceFactoryVPC.build(); + + expect(getLinodeInterfaceType(networkInterface)).toBe('vpc'); + }); + + it("returns 'vlan' if the given interface defines a VLAN interface", () => { + const networkInterface = linodeInterfaceFactoryVlan.build(); + + expect(getLinodeInterfaceType(networkInterface)).toBe('vlan'); + }); +}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/utilities.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/utilities.ts new file mode 100644 index 00000000000..68c5fa36ac0 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeInterfaces/utilities.ts @@ -0,0 +1,13 @@ +import type { LinodeInterface } from '@linode/api-v4'; + +export const getLinodeInterfaceType = (networkInterface: LinodeInterface) => { + if (networkInterface.vpc) { + return 'vpc'; + } + if (networkInterface.vlan) { + return 'vlan'; + } + return 'public'; +}; + +export type LinodeInterfaceType = ReturnType; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetwork.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetwork.tsx deleted file mode 100644 index 486ca340ba1..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetwork.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Stack } from '@linode/ui'; -import * as React from 'react'; -import { useParams } from 'react-router-dom'; - -import { LinodeFirewalls } from './LinodeFirewalls/LinodeFirewalls'; -import { LinodeIPAddresses } from './LinodeIPAddresses'; -import { LinodeNetworkingSummaryPanel } from './NetworkingSummaryPanel/NetworkingSummaryPanel'; - -export const LinodeStorage = () => { - const { linodeId } = useParams<{ linodeId: string }>(); - const _linodeId = Number(linodeId); - - return ( - - - - - - ); -}; - -export default LinodeStorage; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworking.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworking.tsx new file mode 100644 index 00000000000..3189f66f20a --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworking.tsx @@ -0,0 +1,43 @@ +import { CircleProgress, ErrorState, Stack } from '@linode/ui'; +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import { useLinodeQuery } from 'src/queries/linodes/linodes'; +import { useIsLinodeInterfacesEnabled } from 'src/utilities/linodes'; + +import { LinodeFirewalls } from './LinodeFirewalls/LinodeFirewalls'; +import { LinodeInterfaces } from './LinodeInterfaces/LinodeInterfaces'; +import { LinodeIPAddresses } from './LinodeIPAddresses'; +import { LinodeNetworkingSummaryPanel } from './NetworkingSummaryPanel/NetworkingSummaryPanel'; + +export const LinodeNetworking = () => { + const { isLinodeInterfacesEnabled } = useIsLinodeInterfacesEnabled(); + const { linodeId } = useParams<{ linodeId: string }>(); + const id = Number(linodeId); + + const { data: linode, error, isPending } = useLinodeQuery(id); + + if (isPending) { + return ; + } + + if (error) { + return ; + } + + const showInterfacesTable = + isLinodeInterfacesEnabled && linode.interface_generation === 'linode'; + + const showFirewallsTable = + !linode.interface_generation || + linode.interface_generation === 'legacy_config'; + + return ( + + + {showFirewallsTable && } + {showInterfacesTable && } + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx index 9f9871b4b24..efcf57476af 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeNetworkingActionMenu.tsx @@ -1,7 +1,6 @@ import { Box } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; -import { isEmpty } from 'ramda'; import * as React from 'react'; import { ActionMenu } from 'src/components/ActionMenu/ActionMenu'; @@ -102,7 +101,7 @@ export const LinodeNetworkingActionMenu = (props: Props) => { : null, ].filter(Boolean) as Action[]; - return !isEmpty(actions) ? ( + return actions.length > 0 ? ( <> {!matchesMdDown && actions.map((action) => { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/DNSResolvers.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/DNSResolvers.tsx index 252044f15e0..f2278348a38 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/DNSResolvers.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/DNSResolvers.tsx @@ -1,6 +1,6 @@ import { Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { useRegionsQuery } from 'src/queries/regions/regions'; @@ -55,7 +55,7 @@ export const DNSResolvers = React.memo((props: DNSResolversProps) => { paddingBottom: 0, paddingTop: 0, }} - xs={12} + size={12} > DNS Resolvers @@ -66,7 +66,7 @@ export const DNSResolvers = React.memo((props: DNSResolversProps) => { gridArea: 'two', paddingRight: theme.spacing(2), }} - xs="auto" + size="auto" > {renderIPResolvers(v4Resolvers)}
    @@ -75,7 +75,7 @@ export const DNSResolvers = React.memo((props: DNSResolversProps) => { gridArea: 'three', paddingLeft: theme.spacing(2), }} - xs="auto" + size="auto" > {renderIPResolvers(v6Resolvers)}
    diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx index d08415cd49c..9eb4048654c 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/NetworkingSummaryPanel.tsx @@ -1,8 +1,8 @@ +import { Paper } from '@linode/ui'; +import Grid from '@mui/material/Grid2'; import { styled, useTheme } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; -import { Paper } from '@linode/ui'; import { useIsGeckoEnabled } from 'src/components/RegionSelect/RegionSelect.utils'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; @@ -31,7 +31,13 @@ export const LinodeNetworkingSummaryPanel = React.memo((props: Props) => { {hideNetworkTransfer ? null : ( // Distributed compute instances have no transfer pool - + { )} { sx={{ paddingBottom: 0, }} - md={3.5} - sm={hideNetworkTransfer ? 12 : 6} - xs={12} + size={{ md: 3.5, sm: hideNetworkTransfer ? 12 : 6, xs: 12 }} > diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferContent.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferContent.tsx index b8ca057e123..6c0d5fdfa71 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferContent.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferContent.tsx @@ -1,6 +1,6 @@ import { CircleProgress, Notice } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { BarPercent } from 'src/components/BarPercent'; @@ -72,7 +72,12 @@ export const TransferContent = (props: ContentProps) => { if (loading) { return ( - + diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx index f13b19b10ba..9827ab2c394 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/NetworkingSummaryPanel/TransferHistory.tsx @@ -1,4 +1,4 @@ -import { Box, CircleProgress, Typography } from '@linode/ui'; +import { Box, CircleProgress, ErrorState, Typography } from '@linode/ui'; import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; import { IconButton } from '@mui/material'; @@ -8,7 +8,6 @@ import * as React from 'react'; import PendingIcon from 'src/assets/icons/pending.svg'; import { AreaChart } from 'src/components/AreaChart/AreaChart'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { convertNetworkToUnit, generateNetworkUnits, diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Actions.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Actions.tsx new file mode 100644 index 00000000000..702abfbd5f3 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Actions.tsx @@ -0,0 +1,26 @@ +import { Button, Stack } from '@linode/ui'; +import React from 'react'; +import { useFormContext } from 'react-hook-form'; + +import type { RebuildLinodeFormValues } from './utils'; + +interface Props { + disabled: boolean; +} + +export const Actions = (props: Props) => { + const { formState } = useFormContext(); + + return ( + + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Confirmation.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Confirmation.tsx new file mode 100644 index 00000000000..ff98d03616f --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Confirmation.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { TypeToConfirm } from 'src/components/TypeToConfirm/TypeToConfirm'; +import { usePreferences } from 'src/queries/profile/preferences'; + +import type { RebuildLinodeFormValues } from './utils'; + +interface Props { + disabled: boolean; + linodeLabel: string; +} + +export const Confirmation = (props: Props) => { + const { control } = useFormContext(); + + const { data: isTypeToConfirmEnabled } = usePreferences( + (preferences) => preferences?.type_to_confirm ?? true + ); + + return ( + ( + + To confirm these changes, type the label of the Linode ( + {props.linodeLabel}) in the field below: + + } + disabled={props.disabled} + errorText={fieldState.error?.message} + hideLabel + label="Linode Label" + onChange={field.onChange} + title="Confirm" + titleVariant="h3" + value={field.value ?? ''} + visible={isTypeToConfirmEnabled} + /> + )} + control={control} + name="confirmationText" + /> + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/DiskEncryption.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/DiskEncryption.tsx new file mode 100644 index 00000000000..e020ceea3ec --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/DiskEncryption.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { Encryption } from 'src/components/Encryption/Encryption'; +import { + getDiskEncryptionDisabledInRebuildReason, + getRebuildDiskEncryptionDescription, + useIsDiskEncryptionFeatureEnabled, +} from 'src/components/Encryption/utils'; +import { useRegionQuery } from 'src/queries/regions/regions'; + +import type { RebuildLinodeFormValues } from './utils'; + +interface Props { + disabled: boolean; + isLKELinode: boolean; + linodeRegion: string; +} + +export const DiskEncryption = (props: Props) => { + const { disabled, isLKELinode, linodeRegion } = props; + const { control } = useFormContext(); + + const { data: region } = useRegionQuery(linodeRegion); + + const isLinodeInDistributedRegion = region?.site_type === 'distributed'; + + // "Disk Encryption" indicates general availability and "LA Disk Encryption" indicates limited availability + const regionSupportsDiskEncryption = + (region?.capabilities.includes('Disk Encryption') || + region?.capabilities.includes('LA Disk Encryption')) ?? + false; + + const { + isDiskEncryptionFeatureEnabled, + } = useIsDiskEncryptionFeatureEnabled(); + + if (!isDiskEncryptionFeatureEnabled) { + return null; + } + + const disableDiskEncryptionReason = getDiskEncryptionDisabledInRebuildReason({ + isLKELinode, + isLinodeInDistributedRegion, + regionSupportsDiskEncryption, + }); + + const description = getRebuildDiskEncryptionDescription({ + isLKELinode, + isLinodeInDistributedRegion, + }); + + return ( + ( + + field.onChange(checked ? 'enabled' : 'disabled') + } + descriptionCopy={description} + disabled={disabled || disableDiskEncryptionReason !== undefined} + disabledReason={disableDiskEncryptionReason} + error={fieldState.error?.message} + isEncryptEntityChecked={field.value === 'enabled'} + /> + )} + control={control} + name="disk_encryption" + /> + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Image.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Image.tsx new file mode 100644 index 00000000000..57400db3ae4 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Image.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; + +import { ImageSelect } from 'src/components/ImageSelect/ImageSelect'; +import { useStackScriptQuery } from 'src/queries/stackscripts'; + +import type { RebuildLinodeFormValues } from './utils'; +import type { Image as ImageType, StackScript } from '@linode/api-v4'; + +interface Props { + disabled: boolean; +} + +export const Image = (props: Props) => { + const { control } = useFormContext(); + + const stackscriptId = useWatch({ + control, + name: 'stackscript_id', + }); + + const { data: stackscript, isLoading } = useStackScriptQuery( + stackscriptId ?? -1, + Boolean(stackscriptId) + ); + + return ( + ( + field.onChange(value?.id ?? null)} + value={field.value ?? null} + variant="all" + /> + )} + control={control} + name="image" + /> + ); +}; + +function getImageSelectFilter(stackscript: StackScript | undefined) { + if (!stackscript) { + // If no StackScript is selected, we don't need to filter. + return undefined; + } + if (stackscript && stackscript.images.includes('any/all')) { + // If a StackScript is selected and it allows any image, we don't need to do any filtering. + return undefined; + } + // If we made it here, a StackScript is selected. We only want to show images that a StackScript supports. + return (image: ImageType) => stackscript?.images.includes(image.id); +} diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/ImageEmptyState.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/ImageEmptyState.tsx deleted file mode 100644 index b1cde9d047f..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/ImageEmptyState.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { Notice, Paper, Typography } from '@linode/ui'; -import { useTheme } from '@mui/material/styles'; -import * as React from 'react'; - -import type { SxProps, Theme } from '@mui/material/styles'; - -interface Props { - className?: string; - errorText: string | undefined; - sx?: SxProps; -} - -export const ImageEmptyState = (props: Props) => { - const { className, errorText, sx } = props; - const theme = useTheme(); - - return ( - - {errorText ? : null} - - Select Image - - - No Compatible Images Available - - - ); -}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx index f6450dc4fae..d26ae094a87 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildDialog.tsx @@ -1,18 +1,9 @@ -import { Autocomplete, Dialog, Notice, Typography } from '@linode/ui'; -import { styled, useTheme } from '@mui/material/styles'; -import * as React from 'react'; +import { Dialog } from '@linode/ui'; +import React from 'react'; -import { ErrorMessage } from 'src/components/ErrorMessage'; -import { getIsDistributedRegion } from 'src/components/RegionSelect/RegionSelect.utils'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; -import { useGrants, useProfile } from 'src/queries/profile/profile'; -import { useRegionsQuery } from 'src/queries/regions/regions'; -import { scrollErrorIntoViewV2 } from 'src/utilities/scrollErrorIntoViewV2'; -import { HostMaintenanceError } from '../HostMaintenanceError'; -import { LinodePermissionsError } from '../LinodePermissionsError'; -import { RebuildFromImage } from './RebuildFromImage'; -import { RebuildFromStackScript } from './RebuildFromStackScript'; +import { LinodeRebuildForm } from './LinodeRebuildForm'; interface Props { linodeId: number | undefined; @@ -21,195 +12,25 @@ interface Props { open: boolean; } -type MODES = - | 'fromAccountStackScript' - | 'fromCommunityStackScript' - | 'fromImage'; - -const options: { - label: string; - value: MODES; -}[] = [ - { label: 'From Image', value: 'fromImage' }, - { label: 'From Community StackScript', value: 'fromCommunityStackScript' }, - { label: 'From Account StackScript', value: 'fromAccountStackScript' }, -]; - -const passwordHelperText = 'Set a password for your rebuilt Linode.'; - export const LinodeRebuildDialog = (props: Props) => { const { linodeId, linodeLabel, onClose, open } = props; - const modalRef = React.useRef(null); - const { data: profile } = useProfile(); - const { data: grants } = useGrants(); - const { data: linode } = useLinodeQuery( + const { data: linode, error, isLoading } = useLinodeQuery( linodeId ?? -1, - linodeId !== undefined && open - ); - - const { data: regionsData } = useRegionsQuery(); - - const isReadOnly = - Boolean(profile?.restricted) && - grants?.linode.find((grant) => grant.id === linodeId)?.permissions === - 'read_only'; - - const hostMaintenance = linode?.status === 'stopped'; - const unauthorized = isReadOnly; - const disabled = hostMaintenance || unauthorized; - - // LDE-related checks - const isEncrypted = linode?.disk_encryption === 'enabled'; - const isLKELinode = Boolean(linode?.lke_cluster_id); - const linodeIsInDistributedRegion = getIsDistributedRegion( - regionsData ?? [], - linode?.region ?? '' + linodeId !== undefined ); - const theme = useTheme(); - - const [mode, setMode] = React.useState('fromImage'); - const [rebuildError, setRebuildError] = React.useState(''); - - const [ - diskEncryptionEnabled, - setDiskEncryptionEnabled, - ] = React.useState(isEncrypted); - - const onExitDrawer = () => { - setRebuildError(''); - setMode('fromImage'); - }; - - const handleRebuildError = (status: string) => { - setRebuildError(status); - scrollErrorIntoViewV2(modalRef); - }; - - const toggleDiskEncryptionEnabled = () => { - setDiskEncryptionEnabled(!diskEncryptionEnabled); - }; - return ( - - {unauthorized && } - {hostMaintenance && } - {rebuildError && ( - - - - )} - - If you can’t rescue an existing disk, it’s time to rebuild - your Linode. There are a couple of different ways you can do this: - either restore from a backup or start over with a fresh Linux - distribution.  - - Rebuilding will destroy all data on all existing disks on this - Linode. - - - { - setMode(selected?.value ?? 'fromImage'); - setRebuildError(''); - }} - textFieldProps={{ - hideLabel: true, - }} - defaultValue={options.find((option) => option.value === mode)} - disableClearable - disabled={disabled} - label="From Image" - options={options} - /> - - {mode === 'fromImage' && ( - - )} - {mode === 'fromCommunityStackScript' && ( - - )} - {mode === 'fromAccountStackScript' && ( - - )} + {linode && } ); }; - -const StyledDiv = styled('div', { label: 'StyledDiv' })(({ theme }) => ({ - '& + div': { - '& .MuiPaper-root': { - '& .MuiTableCell-head': { - top: theme.spacing(11), - }, - '& > div': { - padding: 0, - }, - padding: 0, - }, - '& .notice': { - padding: theme.spacing(2), - }, - padding: 0, - }, - paddingBottom: theme.spacing(2), -})); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildForm.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildForm.tsx new file mode 100644 index 00000000000..268439c16c3 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/LinodeRebuildForm.tsx @@ -0,0 +1,181 @@ +import { isEmpty } from '@linode/api-v4'; +import { Divider, Notice, Stack, Typography } from '@linode/ui'; +import { useQueryClient } from '@tanstack/react-query'; +import { useSnackbar } from 'notistack'; +import React, { useEffect, useRef, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { useLocation } from 'react-router-dom'; + +import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; +import { useEventsPollingActions } from 'src/queries/events/events'; +import { useRebuildLinodeMutation } from 'src/queries/linodes/linodes'; +import { usePreferences } from 'src/queries/profile/preferences'; +import { utoa } from 'src/utilities/metadata'; +import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; + +import { StackScriptSelectionList } from '../../LinodeCreate/Tabs/StackScripts/StackScriptSelectionList'; +import { LinodePermissionsError } from '../LinodePermissionsError'; +import { Actions } from './Actions'; +import { Confirmation } from './Confirmation'; +import { DiskEncryption } from './DiskEncryption'; +import { Image } from './Image'; +import { Password } from './Password'; +import { RebuildFromSelect } from './RebuildFrom'; +import { SSHKeys } from './SSHKeys'; +import { UserData } from './UserData'; +import { UserDefinedFields } from './UserDefinedFields'; +import { REBUILD_LINODE_IMAGE_PARAM_NAME, resolver } from './utils'; + +import type { + Context, + LinodeRebuildType, + RebuildLinodeFormValues, +} from './utils'; +import type { Linode } from '@linode/api-v4'; + +interface Props { + linode: Linode; + onSuccess: () => void; +} + +export const LinodeRebuildForm = (props: Props) => { + const { linode, onSuccess } = props; + const { enqueueSnackbar } = useSnackbar(); + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); + + const [type, setType] = useState('Image'); + + const isLinodeReadOnly = useIsResourceRestricted({ + grantLevel: 'read_only', + grantType: 'linode', + id: linode.id, + }); + + const { data: isTypeToConfirmEnabled } = usePreferences( + (preferences) => preferences?.type_to_confirm ?? true + ); + + const queryClient = useQueryClient(); + const { mutateAsync: rebuildLinode } = useRebuildLinodeMutation(linode.id); + const { checkForNewEvents } = useEventsPollingActions(); + + const form = useForm({ + context: { + isTypeToConfirmEnabled, + linodeLabel: linode.label, + queryClient, + type, + }, + defaultValues: { + disk_encryption: linode.disk_encryption, + image: queryParams.get(REBUILD_LINODE_IMAGE_PARAM_NAME) ?? undefined, + metadata: { + user_data: null, + }, + reuseUserData: false, + }, + resolver, + }); + + const onSubmit = async (values: RebuildLinodeFormValues) => { + if (values.reuseUserData) { + values.metadata = undefined; + } else if (values.metadata?.user_data) { + values.metadata.user_data = utoa(values.metadata.user_data); + } + + // Distributed instances are encrypted by default and disk_encryption should not be included in the payload. + if (linode.site_type === 'distributed') { + values.disk_encryption = undefined; + } + + try { + await rebuildLinode(values); + + enqueueSnackbar('Linode rebuild started.', { variant: 'info' }); + checkForNewEvents(); + onSuccess(); + } catch (errors) { + for (const error of errors) { + form.setError(error.field ?? 'root', { message: error.reason }); + } + } + }; + + const previousSubmitCount = useRef(0); + + useEffect(() => { + if ( + !isEmpty(form.formState.errors) && + form.formState.submitCount > previousSubmitCount.current + ) { + scrollErrorIntoView(undefined, { behavior: 'smooth' }); + } + previousSubmitCount.current = form.formState.submitCount; + }, [form.formState]); + + return ( + +
    + + {isLinodeReadOnly && } + {form.formState.errors.root && ( + + )} + + If you can’t rescue an existing disk, it’s time to rebuild your + Linode. There are a couple of different ways you can do this: either + restore from a backup or start over with a fresh Linux distribution.{' '} + + Rebuilding will destroy all data on all existing disks on this + Linode. + + + } + spacing={2} + > + + {form.formState.errors.stackscript_id?.message && ( + + )} + {type === 'Account StackScript' && ( + + )} + {type === 'Community StackScript' && ( + + )} + {type.includes('StackScript') && } + + + + + + + + + +
    +
    + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Password.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Password.tsx new file mode 100644 index 00000000000..05a1893b7ff --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/Password.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { PasswordInput } from 'src/components/PasswordInput/PasswordInput'; + +import type { RebuildLinodeFormValues } from './utils'; + +interface Props { + disabled: boolean; +} + +export const Password = (props: Props) => { + const { control } = useFormContext(); + + return ( + ( + + )} + control={control} + name="root_pass" + /> + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFrom.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFrom.tsx new file mode 100644 index 00000000000..dbe2d24ac49 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFrom.tsx @@ -0,0 +1,38 @@ +import { Autocomplete } from '@linode/ui'; +import React from 'react'; +import { useFormContext } from 'react-hook-form'; + +import { REBUILD_OPTIONS } from './utils'; + +import type { LinodeRebuildType, RebuildLinodeFormValues } from './utils'; + +interface Props { + disabled: boolean; + setType: (type: LinodeRebuildType) => void; + type: LinodeRebuildType; +} + +export const RebuildFromSelect = (props: Props) => { + const { disabled, setType, type } = props; + const { reset } = useFormContext(); + + return ( + { + reset((values) => ({ + ...values, + image: '', + stackscript_data: undefined, + stackscript_id: undefined, + })); + setType(value.label); + }} + disableClearable + disabled={disabled} + label="Rebuild From" + noMarginTop + options={REBUILD_OPTIONS} + value={REBUILD_OPTIONS.find((o) => o.label === type)} + /> + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.styles.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.styles.ts deleted file mode 100644 index bf473765e04..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.styles.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Notice } from '@linode/ui'; -import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; - -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; - -export const StyledNotice = styled(Notice, { label: 'StyledNotice' })({ - marginBottom: '0px !important', - // @TODO: Remove the !important's once Notice.tsx has been refactored to use MUI's styled() - padding: '8px !important', -}); - -export const StyledActionsPanel = styled(ActionsPanel, { - label: 'StyledActionPanel', -})(({ theme }) => ({ - '& button': { - alignSelf: 'flex-end', - }, - justifyContent: 'flex-start', - marginTop: theme.spacing(2), - paddingTop: 0, -})); - -export const StyledGrid = styled(Grid, { label: 'StyledGrid' })( - ({ theme }) => ({ - paddingTop: theme.spacing(3), - }) -); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.test.tsx deleted file mode 100644 index 66b01afb0b3..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import * as React from 'react'; - -import { reactRouterProps } from 'src/__data__/reactRouterProps'; -import { wrapWithTheme } from 'src/utilities/testHelpers'; -import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; - -import { RebuildFromImage } from './RebuildFromImage'; - -vi.mock('src/utilities/scrollErrorIntoView'); - -const props = { - disabled: false, - diskEncryptionEnabled: true, - handleRebuildError: vi.fn(), - isLKELinode: false, - linodeId: 1234, - linodeIsInDistributedRegion: false, - onClose: vi.fn(), - passwordHelperText: '', - toggleDiskEncryptionEnabled: vi.fn(), - ...reactRouterProps, -}; - -const diskEncryptionEnabledMock = vi.hoisted(() => { - return { - useIsDiskEncryptionFeatureEnabled: vi.fn(), - }; -}); - -describe('RebuildFromImage', () => { - vi.mock('src/components/Encryption/utils.ts', async () => { - const actual = await vi.importActual( - 'src/components/Encryption/utils.ts' - ); - return { - ...actual, - __esModule: true, - useIsDiskEncryptionFeatureEnabled: diskEncryptionEnabledMock.useIsDiskEncryptionFeatureEnabled.mockImplementation( - () => { - return { - isDiskEncryptionFeatureEnabled: false, // indicates the feature flag is off or account capability is absent - }; - } - ), - }; - }); - - it('renders a SelectImage panel', () => { - const { queryByText } = renderWithThemeAndHookFormContext({ - component: wrapWithTheme(), - }); - expect(queryByText('Select Image')).toBeInTheDocument(); - }); - - // @TODO LDE: Remove feature flagging/conditionality once LDE is fully rolled out - it('does not render a "Disk Encryption" section when the Disk Encryption feature is disabled', () => { - const { queryByText } = renderWithThemeAndHookFormContext({ - component: , - }); - - expect(queryByText('Encrypt Disk')).not.toBeInTheDocument(); - }); - - it('renders a "Disk Encryption" section when the Disk Encryption feature is enabled', () => { - diskEncryptionEnabledMock.useIsDiskEncryptionFeatureEnabled.mockImplementationOnce( - () => { - return { - isDiskEncryptionFeatureEnabled: true, - }; - } - ); - - const { queryByText } = renderWithThemeAndHookFormContext({ - component: , - }); - - expect(queryByText('Encrypt Disk')).toBeInTheDocument(); - }); -}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx deleted file mode 100644 index b7aa6f58977..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromImage.tsx +++ /dev/null @@ -1,338 +0,0 @@ -import { rebuildLinode } from '@linode/api-v4'; -import { Box, Checkbox, Divider, Typography } from '@linode/ui'; -import { RebuildLinodeSchema } from '@linode/validation/lib/linodes.schema'; -import Grid from '@mui/material/Unstable_Grid2'; -import { Formik } from 'formik'; -import { useSnackbar } from 'notistack'; -import { isEmpty } from 'ramda'; -import * as React from 'react'; -import { useLocation } from 'react-router-dom'; - -import { AccessPanel } from 'src/components/AccessPanel/AccessPanel'; -import { ImageSelect } from 'src/components/ImageSelect/ImageSelect'; -import { TypeToConfirm } from 'src/components/TypeToConfirm/TypeToConfirm'; -import { useFlags } from 'src/hooks/useFlags'; -import { useEventsPollingActions } from 'src/queries/events/events'; -import { usePreferences } from 'src/queries/profile/preferences'; -import { useRegionsQuery } from 'src/queries/regions/regions'; -import { - handleFieldErrors, - handleGeneralErrors, -} from 'src/utilities/formikErrorUtils'; -import { regionSupportsMetadata } from 'src/utilities/metadata'; -import { getQueryParamFromQueryString } from 'src/utilities/queryParams'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; -import { extendValidationSchema } from 'src/utilities/validatePassword'; - -import { - StyledActionsPanel, - StyledGrid, - StyledNotice, -} from './RebuildFromImage.styles'; -import { UserDataAccordion } from './UserDataAccordion/UserDataAccordion'; - -import type { Image, RebuildRequest, UserData } from '@linode/api-v4'; -import type { FormikProps } from 'formik'; - -interface Props { - disabled: boolean; - diskEncryptionEnabled: boolean; - handleRebuildError: (status: string) => void; - isLKELinode: boolean; - linodeId: number; - linodeIsInDistributedRegion: boolean; - linodeLabel?: string; - linodeRegion?: string; - onClose: () => void; - passwordHelperText: string; - toggleDiskEncryptionEnabled: () => void; -} - -interface RebuildFromImageForm { - authorized_users: string[]; - image: string; - metadata?: UserData; - root_pass: string; -} - -const initialValues: RebuildFromImageForm = { - authorized_users: [], - image: '', - metadata: { - user_data: '', - }, - root_pass: '', -}; - -export const REBUILD_LINODE_IMAGE_PARAM_NAME = 'selectedImageId'; - -export const RebuildFromImage = (props: Props) => { - const { - disabled, - diskEncryptionEnabled, - handleRebuildError, - isLKELinode, - linodeId, - linodeIsInDistributedRegion, - linodeLabel, - linodeRegion, - onClose, - passwordHelperText, - toggleDiskEncryptionEnabled, - } = props; - - const { - data: typeToConfirmPreference, - isLoading: isLoadingPreferences, - } = usePreferences((preferences) => preferences?.type_to_confirm ?? true); - - const { checkForNewEvents } = useEventsPollingActions(); - - const { enqueueSnackbar } = useSnackbar(); - const flags = useFlags(); - - const { data: regionsData, isLoading: isLoadingRegions } = useRegionsQuery(); - const isLoading = isLoadingPreferences || isLoadingRegions; - - const RebuildSchema = () => extendValidationSchema(RebuildLinodeSchema); - - const [confirmationText, setConfirmationText] = React.useState(''); - const [isCloudInit, setIsCloudInit] = React.useState(false); - - const [userData, setUserData] = React.useState(''); - const [shouldReuseUserData, setShouldReuseUserData] = React.useState( - false - ); - - const location = useLocation(); - const preselectedImageId = getQueryParamFromQueryString( - location.search, - REBUILD_LINODE_IMAGE_PARAM_NAME, - '' - ); - - const handleUserDataChange = (userData: string) => { - setUserData(userData); - }; - - const handleShouldReuseUserDataChange = () => { - setShouldReuseUserData((shouldReuseUserData) => !shouldReuseUserData); - }; - - React.useEffect(() => { - if (shouldReuseUserData) { - setUserData(''); - } - }, [shouldReuseUserData]); - - const submitButtonDisabled = - Boolean(typeToConfirmPreference) && confirmationText !== linodeLabel; - - const handleFormSubmit = ( - { authorized_users, image, root_pass }: RebuildFromImageForm, - { setErrors, setStatus, setSubmitting }: FormikProps - ) => { - setSubmitting(true); - - // `status` holds general error messages - setStatus(undefined); - - const params: RebuildRequest = { - authorized_users, - disk_encryption: diskEncryptionEnabled ? 'enabled' : 'disabled', - image, - metadata: { - user_data: userData - ? window.btoa(userData) - : !userData && !shouldReuseUserData - ? null - : '', - }, - root_pass, - }; - - /* - User Data logic: - 1) if user data has been provided, encode it and include it in the payload - 2) if user data has not been provided and the Reuse User Data checkbox is - not checked, send null in the payload - 3) if the Reuse User Data checkbox is checked, remove the Metadata property from the payload. - */ - if (shouldReuseUserData) { - delete params['metadata']; - } - - // if the linode is part of an LKE cluster or is in a Distributed region, the disk_encryption value - // cannot be changed, so omit it from the payload - if (isLKELinode || linodeIsInDistributedRegion) { - delete params['disk_encryption']; - } - - // @todo: eventually this should be a dispatched action instead of a services library call - rebuildLinode(linodeId, params) - .then((_) => { - // Reset events polling since an in-progress event (rebuild) is happening. - checkForNewEvents(); - - setSubmitting(false); - - enqueueSnackbar('Linode rebuild started', { - variant: 'info', - }); - onClose(); - }) - .catch((errorResponse) => { - const defaultMessage = `There was an issue rebuilding your Linode.`; - const mapErrorToStatus = (generalError: string) => - setStatus({ generalError }); - - setSubmitting(false); - handleFieldErrors(setErrors, errorResponse); - handleGeneralErrors(mapErrorToStatus, errorResponse, defaultMessage); - scrollErrorIntoView(); - }); - }; - - return ( - - {({ - errors, - handleSubmit, - setFieldValue, - status, // holds generalError messages - validateForm, - values, - }) => { - // We'd like to validate the form before submitting. - const handleRebuildButtonClick = () => { - // Validate stackscript_id, image, & root_pass - validateForm().then((maybeErrors) => { - // If there aren't any errors, we can submit the form. - if (isEmpty(maybeErrors)) { - handleSubmit(); - // The form receives the errors automatically, and we scroll them into view. - } else { - scrollErrorIntoView(); - } - }); - }; - - const handleImageChange = (image: Image | null) => { - setFieldValue('image', image?.id ?? ''); - setIsCloudInit(image?.capabilities?.includes('cloud-init') ?? false); - }; - - if (status) { - handleRebuildError(status.generalError); - } - - const shouldDisplayUserDataAccordion = - flags.metadata && - regionSupportsMetadata(regionsData ?? [], linodeRegion ?? '') && - isCloudInit; - - return ( - -
    - Select Image - - - setFieldValue('authorized_users', usernames) - } - authorizedUsers={values.authorized_users} - data-qa-access-panel - disabled={disabled} - diskEncryptionEnabled={diskEncryptionEnabled} - displayDiskEncryption - error={errors.root_pass} - handleChange={(input) => setFieldValue('root_pass', input)} - isInRebuildFlow - isLKELinode={isLKELinode} - linodeIsInDistributedRegion={linodeIsInDistributedRegion} - password={values.root_pass} - passwordHelperText={passwordHelperText} - selectedRegion={linodeRegion} - toggleDiskEncryptionEnabled={toggleDiskEncryptionEnabled} - /> - {shouldDisplayUserDataAccordion ? ( - <> - - - - - } - renderNotice={ - - } - disabled={shouldReuseUserData} - onChange={handleUserDataChange} - userData={userData} - /> - - ) : null} - ({ - marginTop: theme.spacing(2), - })} - > - - To confirm these changes, type the label of the Linode ( - {linodeLabel}) in the field below: - - } - onChange={(input) => { - setConfirmationText(input); - }} - hideLabel - label="Linode Label" - textFieldStyle={{ marginBottom: 16 }} - title="Confirm" - typographyStyle={{ marginBottom: 8 }} - value={confirmationText} - visible={typeToConfirmPreference} - /> - - - - -
    - ); - }} -
    - ); -}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.test.tsx deleted file mode 100644 index 034ade1de01..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { fireEvent, render, waitFor } from '@testing-library/react'; -import * as React from 'react'; - -import { reactRouterProps } from 'src/__data__/reactRouterProps'; -import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers'; - -import { RebuildFromStackScript } from './RebuildFromStackScript'; - -const props = { - disabled: false, - diskEncryptionEnabled: true, - handleRebuildError: vi.fn(), - isLKELinode: false, - linodeId: 1234, - linodeIsInDistributedRegion: false, - onClose: vi.fn(), - passwordHelperText: '', - toggleDiskEncryptionEnabled: vi.fn(), - type: 'community' as const, - ...reactRouterProps, -}; - -const diskEncryptionEnabledMock = vi.hoisted(() => { - return { - useIsDiskEncryptionFeatureEnabled: vi.fn(), - }; -}); - -describe('RebuildFromStackScript', () => { - vi.mock('src/components/Encryption/utils.ts', async () => { - const actual = await vi.importActual( - 'src/components/Encryption/utils.ts' - ); - return { - ...actual, - __esModule: true, - useIsDiskEncryptionFeatureEnabled: diskEncryptionEnabledMock.useIsDiskEncryptionFeatureEnabled.mockImplementation( - () => { - return { - isDiskEncryptionFeatureEnabled: false, // indicates the feature flag is off or account capability is absent - }; - } - ), - }; - }); - - it('renders a SelectImage panel', () => { - const { queryByText } = render( - wrapWithTheme() - ); - expect(queryByText('Select Image')).toBeInTheDocument(); - }); - - it('renders a SelectStackScript panel', () => { - const { queryByPlaceholderText } = render( - wrapWithTheme() - ); - expect(queryByPlaceholderText('Search by Label, Username, or Description')); - }); - - it.skip('validates the form upon clicking the "Rebuild" button', async () => { - const { getByTestId, getByText } = render( - wrapWithTheme() - ); - fireEvent.click(getByTestId('rebuild-button')); - await waitFor( - () => [ - getByText('A StackScript is required.'), - getByText('An image is required.'), - getByText('Password is required.'), - ], - {} - ); - }); - - // @TODO LDE: Remove feature flagging/conditionality once LDE is fully rolled out - it('does not render a "Disk Encryption" section when the Disk Encryption feature is disabled', () => { - const { queryByText } = renderWithTheme( - - ); - - expect(queryByText('Encrypt Disk')).not.toBeInTheDocument(); - }); - - it('renders a "Disk Encryption" section when the Disk Encryption feature is enabled', () => { - diskEncryptionEnabledMock.useIsDiskEncryptionFeatureEnabled.mockImplementationOnce( - () => { - return { - isDiskEncryptionFeatureEnabled: true, - }; - } - ); - - const { queryByText } = renderWithTheme( - - ); - - expect(queryByText('Encrypt Disk')).toBeInTheDocument(); - }); -}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx deleted file mode 100644 index 6327885afad..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/RebuildFromStackScript.tsx +++ /dev/null @@ -1,426 +0,0 @@ -import { rebuildLinode } from '@linode/api-v4/lib/linodes'; -import { RebuildLinodeFromStackScriptSchema } from '@linode/validation/lib/linodes.schema'; -import { useTheme } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; -import { Formik } from 'formik'; -import { useSnackbar } from 'notistack'; -import { isEmpty } from 'ramda'; -import * as React from 'react'; - -import { AccessPanel } from 'src/components/AccessPanel/AccessPanel'; -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { ImageSelect } from 'src/components/ImageSelect/ImageSelect'; -import { TypeToConfirm } from 'src/components/TypeToConfirm/TypeToConfirm'; -import SelectStackScriptPanel from 'src/features/StackScripts/SelectStackScriptPanel/SelectStackScriptPanel'; -import { - getCommunityStackscripts, - getMineAndAccountStackScripts, -} from 'src/features/StackScripts/stackScriptUtils'; -import UserDefinedFieldsPanel from 'src/features/StackScripts/UserDefinedFieldsPanel/UserDefinedFieldsPanel'; -import { useStackScript } from 'src/hooks/useStackScript'; -import { listToItemsByID } from 'src/queries/base'; -import { useEventsPollingActions } from 'src/queries/events/events'; -import { useAllImagesQuery } from 'src/queries/images'; -import { usePreferences } from 'src/queries/profile/preferences'; -import { filterImagesByType } from 'src/store/image/image.helpers'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { - handleFieldErrors, - handleGeneralErrors, -} from 'src/utilities/formikErrorUtils'; -import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; -import { extendValidationSchema } from 'src/utilities/validatePassword'; - -import { StackScriptDetailsDialog } from '../../LinodeCreate/Tabs/StackScripts/StackScriptDetailsDialog'; -import { ImageEmptyState } from './ImageEmptyState'; - -import type { UserDefinedField } from '@linode/api-v4/lib/stackscripts'; -import type { APIError } from '@linode/api-v4/lib/types'; -import type { FormikProps } from 'formik'; - -interface Props { - disabled: boolean; - diskEncryptionEnabled: boolean; - handleRebuildError: (status: string) => void; - isLKELinode: boolean; - linodeId: number; - linodeIsInDistributedRegion: boolean; - linodeLabel?: string; - linodeRegion?: string; - onClose: () => void; - passwordHelperText: string; - toggleDiskEncryptionEnabled: () => void; - type: 'account' | 'community'; -} - -interface RebuildFromStackScriptForm { - authorized_users: string[]; - disk_encryption: string | undefined; - image: string; - root_pass: string; - stackscript_id: string; -} - -const initialValues: RebuildFromStackScriptForm = { - authorized_users: [], - disk_encryption: 'enabled', - image: '', - root_pass: '', - stackscript_id: '', -}; - -export const RebuildFromStackScript = (props: Props) => { - const { - diskEncryptionEnabled, - handleRebuildError, - isLKELinode, - linodeId, - linodeIsInDistributedRegion, - linodeLabel, - linodeRegion, - onClose, - passwordHelperText, - toggleDiskEncryptionEnabled, - } = props; - - const { - data: typeToConfirmPreference, - isLoading: isLoadingPreferences, - } = usePreferences((preferences) => preferences?.type_to_confirm ?? true); - - const { checkForNewEvents } = useEventsPollingActions(); - - const theme = useTheme(); - const { enqueueSnackbar } = useSnackbar(); - - const { data: imagesData, isLoading: isLoadingImages } = useAllImagesQuery(); - const _imagesData = listToItemsByID(imagesData ?? []); - const isLoading = isLoadingPreferences || isLoadingImages; - - /** - * Dynamic validation schema, with password validation - * dependent on a value from a feature flag. Remove this - * once API password validation is stable. - */ - const RebuildSchema = () => - extendValidationSchema(RebuildLinodeFromStackScriptSchema); - - const [confirmationText, setConfirmationText] = React.useState(''); - const submitButtonDisabled = - Boolean(typeToConfirmPreference) && confirmationText !== linodeLabel; - - const [ - ss, - handleSelectStackScript, - handleChangeUDF, - resetStackScript, - ] = useStackScript( - Object.keys(_imagesData).map((eachKey) => _imagesData[eachKey]) - ); - - const [ - selectedStackScriptIdForDetailsDialog, - setSelectedStackScriptIdForDetailsDialog, - ] = React.useState(); - - const onOpenStackScriptDetailsDialog = (stackscriptId: number) => { - setSelectedStackScriptIdForDetailsDialog(stackscriptId); - }; - - // In this component, most errors are handled by Formik. This is not - // possible with UDFs, since they are dynamic. Their errors need to - // be handled separately. - const [udfErrors, setUdfErrors] = React.useState( - undefined - ); - - const handleFormSubmit = ( - { authorized_users, image, root_pass }: RebuildFromStackScriptForm, - { - setErrors, - setStatus, - setSubmitting, - }: FormikProps - ) => { - setSubmitting(true); - - // if the linode is part of an LKE cluster or is in a Distributed region, the disk_encryption value - // cannot be changed, so set it to undefined and the API will disregard it - const diskEncryptionPayloadValue = - isLKELinode || linodeIsInDistributedRegion - ? undefined - : diskEncryptionEnabled - ? 'enabled' - : 'disabled'; - - rebuildLinode(linodeId, { - authorized_users, - disk_encryption: diskEncryptionPayloadValue, - image, - root_pass, - stackscript_data: ss.udf_data, - stackscript_id: ss.id, - }) - .then((_) => { - // Reset events polling since an in-progress event (rebuild) is happening. - checkForNewEvents(); - - setSubmitting(false); - - enqueueSnackbar('Linode rebuild started', { - variant: 'info', - }); - onClose(); - }) - .catch((errorResponse) => { - const APIErrors = getAPIErrorOrDefault(errorResponse); - setUdfErrors(getUDFErrors(APIErrors)); - - const defaultMessage = `There was an issue rebuilding your Linode.`; - const mapErrorToStatus = (generalError: string) => - setStatus({ generalError }); - - setSubmitting(false); - - const modifiedErrors = APIErrors.map((thisError) => { - /** - * Errors returned for attempting to rebuild from an invalid - * StackScript will have a field of 'script' (and an unhelpful - * error message). Since nothing in our form is listening to this - * field, the error will slip through without being shown to the user. - * - * If we have one of those, change the field to stackscriptId, which - * we're listening for in Formik, and use a more helpful message. - */ - if (thisError.field === 'script') { - const reason = thisError.reason.match(/invalid stackscript/i) - ? 'The selected StackScript is invalid.' - : thisError.reason; - return { field: 'stackscript_id', reason }; - } else { - return thisError; - } - }); - - handleFieldErrors(setErrors, modifiedErrors); - handleGeneralErrors(mapErrorToStatus, modifiedErrors, defaultMessage); - - scrollErrorIntoView(); - }); - }; - - // Since UDFs are dynamic, they are not handled by Formik. They need - // to be validated separately. This functions checks if we've got values - // for all REQUIRED UDFs, and sets errors appropriately. - const validateUdfs = () => { - const maybeErrors: APIError[] = []; - - // Walk through the defined UDFs - ss.user_defined_fields.forEach((eachUdf) => { - // Is it required? Do we have a value? - if (isUDFRequired(eachUdf) && !ss.udf_data[eachUdf.name]) { - // If not, we've got an error. - maybeErrors.push({ - field: eachUdf.name, - reason: `A value for the ${eachUdf.name} is required.`, - }); - } - }); - - return maybeErrors; - }; - - return ( - - {({ - errors, - handleSubmit, - setFieldValue, - status, // holds generalError messages - validateForm, - values, - }) => { - // We'd like to validate the form before submitting. - const handleRebuildButtonClick = () => { - // Validate stackscript_id, image, & root_pass - validateForm().then((maybeErrors) => { - // UDFs are not part of Formik - validate separately. - const maybeUDFErrors = validateUdfs(); - setUdfErrors(maybeUDFErrors); - - // If there aren't any errors, we can proceed. - if (isEmpty(maybeErrors) && maybeUDFErrors.length === 0) { - handleSubmit(); - // The form receives the errors automatically, and we scroll them into view. - } else { - scrollErrorIntoView(); - } - }); - }; - - const handleSelect = ( - id: number, - label: string, - username: string, - stackScriptImages: string[], - user_defined_fields: UserDefinedField[] - ) => { - handleSelectStackScript( - id, - label, - username, - stackScriptImages, - user_defined_fields - ); - // Reset Image ID so that that an incompatible image can't be submitted accidentally - setFieldValue('stackscript_id', id); - setFieldValue('image', ''); - }; - - if (status) { - handleRebuildError(status.generalError); - } - - return ( - -
    - - {ss.user_defined_fields && ss.user_defined_fields.length > 0 && ( - - )} - - {ss.images && ss.images.length > 0 ? ( - - setFieldValue('image', image?.id ?? null) - } - errorText={errors.image} - title="Choose Image" - value={values.image} - variant="public" - /> - ) : ( - - )} - - setFieldValue('authorized_users', usernames) - } - authorizedUsers={values.authorized_users} - data-qa-access-panel - diskEncryptionEnabled={diskEncryptionEnabled} - displayDiskEncryption - error={errors.root_pass} - handleChange={(value) => setFieldValue('root_pass', value)} - isInRebuildFlow - isLKELinode={isLKELinode} - linodeIsInDistributedRegion={linodeIsInDistributedRegion} - password={values.root_pass} - passwordHelperText={passwordHelperText} - selectedRegion={linodeRegion} - toggleDiskEncryptionEnabled={toggleDiskEncryptionEnabled} - /> - ({ - marginTop: theme.spacing(2), - })} - > - - To confirm these changes, type the label of the Linode ( - {linodeLabel}) in the field below: - - } - onChange={(input) => { - setConfirmationText(input); - }} - hideLabel - label="Linode Label" - textFieldStyle={{ marginBottom: 16 }} - title="Confirm" - typographyStyle={{ marginBottom: 8 }} - value={confirmationText} - visible={typeToConfirmPreference} - /> - - - - - setSelectedStackScriptIdForDetailsDialog(undefined) - } - id={selectedStackScriptIdForDetailsDialog} - open={selectedStackScriptIdForDetailsDialog !== undefined} - /> -
    - ); - }} -
    - ); -}; - -// ============================================================================= -// Helpers -// ============================================================================= - -const getUDFErrors = (errors: APIError[] | undefined) => { - const fixedErrorFields = ['stackscript_id', 'root_pass', 'image', 'none']; - - return errors - ? errors.filter((error) => { - // ensure the error isn't a root_pass, image, or none - const isNotUDFError = fixedErrorFields.some((errorKey) => { - return errorKey === error.field; - }); - // if the 'field' prop exists and isn't any other error - return !!error.field && !isNotUDFError; - }) - : undefined; -}; - -const isUDFRequired = (udf: UserDefinedField) => !udf.hasOwnProperty('default'); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/SSHKeys.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/SSHKeys.tsx new file mode 100644 index 00000000000..a76117e5221 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/SSHKeys.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { UserSSHKeyPanel } from 'src/components/AccessPanel/UserSSHKeyPanel'; + +import type { RebuildLinodeFormValues } from './utils'; + +interface Props { + disabled: boolean; +} + +export const SSHKeys = (props: Props) => { + const { control } = useFormContext(); + + return ( + ( + + )} + control={control} + name="authorized_users" + /> + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserData.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserData.tsx new file mode 100644 index 00000000000..4a7911aab28 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserData.tsx @@ -0,0 +1,139 @@ +import { Accordion, Checkbox, Notice, TextField, Typography } from '@linode/ui'; +import React from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; + +import { Link } from 'src/components/Link'; +import { useImageQuery } from 'src/queries/images'; +import { useLinodeQuery } from 'src/queries/linodes/linodes'; +import { useRegionQuery } from 'src/queries/regions/regions'; + +import type { RebuildLinodeFormValues } from './utils'; + +interface Props { + disabled: boolean; + linodeId: number; +} + +export const UserData = (props: Props) => { + const { control } = useFormContext(); + + const [imageId, reuseUserData] = useWatch({ + control, + name: ['image', 'reuseUserData'], + }); + + const [formatWarning, setFormatWarning] = React.useState(false); + + const { data: linode } = useLinodeQuery(props.linodeId); + const { data: region } = useRegionQuery(linode?.region ?? ''); + const { data: image } = useImageQuery(imageId ?? '', Boolean(imageId)); + + const checkFormat = ({ + hasInputValueChanged, + userData, + }: { + hasInputValueChanged: boolean; + userData: string; + }) => { + const userDataLower = userData.toLowerCase(); + const validPrefixes = ['#cloud-config', 'content-type: text/', '#!/bin/']; + const isUserDataValid = validPrefixes.some((prefix) => + userDataLower.startsWith(prefix) + ); + setFormatWarning( + userData.length > 0 && !isUserDataValid && !hasInputValueChanged + ); + }; + + const doesRegionSupportMetadata = region?.capabilities.includes('Metadata'); + const doesImageSupportCloudInit = image?.capabilities.includes('cloud-init'); + + const disabled = + props.disabled || !doesRegionSupportMetadata || !doesImageSupportCloudInit; + + return ( + + + Adding new user data is recommended as part of the rebuild process. + + + User data is a feature of the Metadata service that enables you to + perform system configuration tasks (such as adding users and installing + software) by providing custom instructions or scripts to cloud-init. Any + user data should be added at this step and cannot be modified after the + the Linode has been created.{' '} + + Learn more + + . + + {formatWarning && ( + + The user data may be formatted incorrectly. + + )} + {image && !doesImageSupportCloudInit && ( + + The selected Images does not support cloud-init. + + )} + {!image && ( + + Select an Image compatible with clout-init to configure user data. + + )} + {region && !doesRegionSupportMetadata && ( + + This Linode's region does not support metadata. + + )} + ( + { + field.onBlur(); + checkFormat({ + hasInputValueChanged: false, + userData: e.target.value, + }); + }} + onChange={(e) => { + const value = e.target.value; + field.onChange(value === '' ? null : value); + checkFormat({ + hasInputValueChanged: true, + userData: e.target.value, + }); + }} + disabled={reuseUserData || disabled} + errorText={fieldState.error?.message} + expand + label="User Data" + labelTooltipText="Compatible formats include cloud-config data and executable scripts." + multiline + rows={1} + value={field.value ?? ''} + /> + )} + control={control} + name="metadata.user_data" + /> + ( + + )} + control={control} + name="reuseUserData" + /> + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserDataAccordion/UserDataAccordion.test.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserDataAccordion/UserDataAccordion.test.tsx deleted file mode 100644 index 931edd0f9ef..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserDataAccordion/UserDataAccordion.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { fireEvent, screen } from '@testing-library/react'; -import * as React from 'react'; - -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { UserDataAccordion } from './UserDataAccordion'; - -describe('UserDataAccordion', () => { - const onChange = vi.fn(); - const props = { - createType: 'fromImage', - onChange, - userData: 'test data', - } as const; - - it('should render without errors', () => { - const { container } = renderWithTheme(); - expect(container).toBeInTheDocument(); - }); - - it('should call onChange when text is entered into the input', () => { - const { getByLabelText } = renderWithTheme( - - ); - const input = getByLabelText('User Data'); - fireEvent.change(input, { target: { value: 'new test data' } }); - expect(onChange).toHaveBeenCalledWith('new test data'); - }); - - it('should display a warning message if the user data is not in an accepted format', () => { - const onChange = vi.fn(); - const inputValue = '#test-string'; - const { getByLabelText, getByText } = renderWithTheme( - - ); - - const input = getByLabelText('User Data'); - fireEvent.change(input, { target: { value: inputValue } }); - fireEvent.blur(input); // triggers format check - - expect(onChange).toHaveBeenCalledWith(inputValue); - expect( - getByText('The user data may be formatted incorrectly.') - ).toBeInTheDocument(); - }); - - it('should display a custom notice', () => { - renderWithTheme( - Custom notice} /> - ); - - const customNotice = screen.getByText('Custom notice'); - - expect(customNotice).toBeInTheDocument(); - }); - - it('should NOT have a notice when a renderNotice prop is not passed in', () => { - const { queryByTestId } = renderWithTheme( - null} userData={''} /> - ); - - expect(queryByTestId('render-notice')).toBeNull(); - }); -}); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserDataAccordion/UserDataAccordion.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserDataAccordion/UserDataAccordion.tsx deleted file mode 100644 index d0b024708c4..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserDataAccordion/UserDataAccordion.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { Accordion, Box, Notice, TextField, Typography } from '@linode/ui'; -import * as React from 'react'; - -import { Link } from 'src/components/Link'; - -import { UserDataAccordionHeading } from './UserDataAccordionHeading'; - -export interface UserDataAccordionProps { - disabled?: boolean; - onChange: (userData: string) => void; - renderCheckbox?: JSX.Element; - renderNotice?: JSX.Element; - userData: string | undefined; -} - -export const UserDataAccordion = (props: UserDataAccordionProps) => { - const { disabled, onChange, renderCheckbox, renderNotice, userData } = props; - const [formatWarning, setFormatWarning] = React.useState(false); - - const checkFormat = ({ - hasInputValueChanged, - userData, - }: { - hasInputValueChanged: boolean; - userData: string; - }) => { - const userDataLower = userData.toLowerCase(); - const validPrefixes = ['#cloud-config', 'content-type: text/', '#!/bin/']; - const isUserDataValid = validPrefixes.some((prefix) => - userDataLower.startsWith(prefix) - ); - if (userData.length > 0 && !isUserDataValid && !hasInputValueChanged) { - setFormatWarning(true); - } else { - setFormatWarning(false); - } - }; - - const sxDetails = { - padding: `0px 24px 24px ${renderNotice ? 0 : 24}px`, - }; - - return ( - } - > - {renderNotice ? ( - - {renderNotice} - - ) : null} - - User data is a feature of the Metadata service that enables you to - perform system configuration tasks (such as adding users and installing - software) by providing custom instructions or scripts to cloud-init. Any - user data should be added at this step and cannot be modified after the - the Linode has been created.{' '} - - Learn more. - {' '} - - {formatWarning ? ( - - The user data may be formatted incorrectly. - - ) : null} - - checkFormat({ hasInputValueChanged: false, userData: e.target.value }) - } - onChange={(e) => { - checkFormat({ hasInputValueChanged: true, userData: e.target.value }); - onChange(e.target.value); - }} - data-qa-user-data-input - disabled={Boolean(disabled)} - expand - label="User Data" - labelTooltipText="Compatible formats include cloud-config data and executable scripts." - multiline - rows={1} - value={userData} - /> - {renderCheckbox ?? null} - - ); -}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserDataAccordion/UserDataAccordionHeading.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserDataAccordion/UserDataAccordionHeading.tsx deleted file mode 100644 index 774bde79179..00000000000 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserDataAccordion/UserDataAccordionHeading.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Box, TooltipIcon } from '@linode/ui'; -import * as React from 'react'; - -import { Link } from 'src/components/Link'; - -export const UserDataAccordionHeading = () => { - return ( - - Add User Data - - User data allows you to provide additional custom data to cloud-init - to further configure your system.{' '} - - Learn more. - - - } - status="help" - sxTooltipIcon={{ alignItems: 'baseline', padding: '0 8px' }} - /> - - ); -}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserDefinedFields.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserDefinedFields.tsx new file mode 100644 index 00000000000..2022716de2f --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/UserDefinedFields.tsx @@ -0,0 +1,75 @@ +import { Box, Notice, Stack, Typography } from '@linode/ui'; +import React from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import { ShowMoreExpansion } from 'src/components/ShowMoreExpansion'; +import { useStackScriptQuery } from 'src/queries/stackscripts'; + +import { UserDefinedFieldInput } from '../../LinodeCreate/Tabs/StackScripts/UserDefinedFields/UserDefinedFieldInput'; +import { separateUDFsByRequiredStatus } from '../../LinodeCreate/Tabs/StackScripts/UserDefinedFields/utilities'; + +import type { CreateLinodeRequest } from '@linode/api-v4'; + +export const UserDefinedFields = () => { + const { control, formState } = useFormContext(); + + const stackscriptId = useWatch({ + control, + name: 'stackscript_id', + }); + + const hasStackscriptSelected = + stackscriptId !== null && stackscriptId !== undefined; + + const { data: stackscript } = useStackScriptQuery( + stackscriptId ?? -1, + hasStackscriptSelected + ); + + const userDefinedFields = stackscript?.user_defined_fields; + + const [requiredUDFs, optionalUDFs] = separateUDFsByRequiredStatus( + userDefinedFields + ); + + if (!stackscript || userDefinedFields?.length === 0) { + return null; + } + + return ( + + {stackscript.label} Setup + {formState.errors.stackscript_data?.message && ( + + )} + + {requiredUDFs.map((field) => ( + + ))} + + + {optionalUDFs.length !== 0 && ( + + + + These fields are additional configuration options and are not + required for creation. + + + {optionalUDFs.map((field) => ( + + ))} + + + + )} + + + ); +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/utils.ts b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/utils.ts new file mode 100644 index 00000000000..815cf0465c9 --- /dev/null +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRebuild/utils.ts @@ -0,0 +1,111 @@ +import { yupResolver } from '@hookform/resolvers/yup'; +import { isEmpty } from '@linode/api-v4'; +import { RebuildLinodeSchema } from '@linode/validation'; +import { boolean, number, object, string } from 'yup'; + +import { stackscriptQueries } from 'src/queries/stackscripts'; + +import { getIsUDFRequired } from '../../LinodeCreate/Tabs/StackScripts/UserDefinedFields/utilities'; + +import type { RebuildRequest, StackScript } from '@linode/api-v4'; +import type { QueryClient } from '@tanstack/react-query'; +import type { FieldError, FieldErrors, Resolver } from 'react-hook-form'; +import type { ManagerPreferences } from 'src/types/ManagerPreferences'; + +export const REBUILD_OPTIONS = [ + { label: 'Image' }, + { label: 'Community StackScript' }, + { label: 'Account StackScript' }, +] as const; + +export const REBUILD_LINODE_IMAGE_PARAM_NAME = 'selectedImageId'; + +export type LinodeRebuildType = typeof REBUILD_OPTIONS[number]['label']; + +export interface RebuildLinodeFormValues extends RebuildRequest { + confirmationText?: string; + reuseUserData: boolean; +} + +export interface Context { + isTypeToConfirmEnabled: ManagerPreferences['type_to_confirm']; + linodeLabel: string | undefined; + queryClient: QueryClient; + type: LinodeRebuildType; +} + +const RebuildLinodeFromImageSchema = RebuildLinodeSchema.concat( + object({ + confirmationText: string(), + reuseUserData: boolean().required(), + }) +); + +const RebuildLinodeFromStackScriptSchema = RebuildLinodeFromImageSchema.concat( + object({ + stackscript_id: number().required('You must select a StackScript.'), + }) +); + +export const resolver: Resolver = async ( + values, + context, + options +) => { + const schema = + context?.type === 'Image' + ? RebuildLinodeFromImageSchema + : RebuildLinodeFromStackScriptSchema; + + const { errors } = await yupResolver(schema, {}, {})( + values, + context, + options + ); + + if ( + context?.isTypeToConfirmEnabled && + values.confirmationText !== context.linodeLabel + ) { + (errors as FieldErrors)['confirmationText'] = { + message: `You must type the Linode label (${context.linodeLabel}) to confirm.`, + type: 'required', + }; + } + + if (context && values.stackscript_id) { + const stackscript = context.queryClient.getQueryData( + stackscriptQueries.stackscript(values.stackscript_id).queryKey + ); + + if (stackscript) { + const stackScriptErrors: Record = {}; + + for (const udf of stackscript.user_defined_fields) { + const stackscriptData = values.stackscript_data as + | Record + | null + | undefined; + + if (getIsUDFRequired(udf) && !stackscriptData?.[udf.name]) { + stackScriptErrors[udf.name] = { + message: `${udf.label} is required.`, + type: 'required', + }; + } + } + + if (!isEmpty(stackScriptErrors)) { + (errors as FieldErrors)[ + 'stackscript_data' + ] = stackScriptErrors; + } + } + } + + if (errors) { + return { errors, values }; + } + + return { errors: {}, values }; +}; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/DeviceSelection.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/DeviceSelection.tsx index 8f8c95cfead..db276a626b2 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/DeviceSelection.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/DeviceSelection.tsx @@ -1,5 +1,4 @@ import { Autocomplete, FormControl } from '@linode/ui'; -import { defaultTo } from 'ramda'; import * as React from 'react'; import { titlecase } from 'src/features/Linodes/presentation'; @@ -41,7 +40,7 @@ export const DeviceSelection = (props: Props) => { slots, } = props; - const counter = defaultTo(0, props.counter) as number; + const counter = props.counter ?? 0; const diskOrVolumeInErrReason = errorText ? extractDiskOrVolumeId(errorText) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx index b2a5a8d4051..08252e7054f 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeRescue/StandardRescueDialog.tsx @@ -1,11 +1,9 @@ -import { Button, Dialog, Notice, Paper, clamp } from '@linode/ui'; +import { Button, Dialog, ErrorState, Notice, Paper, clamp } from '@linode/ui'; import { styled, useTheme } from '@mui/material/styles'; import { useSnackbar } from 'notistack'; -import { assoc, equals } from 'ramda'; import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { usePrevious } from 'src/hooks/usePrevious'; import { useEventsPollingActions } from 'src/queries/events/events'; import { useAllLinodeDisksQuery } from 'src/queries/linodes/disks'; @@ -113,9 +111,10 @@ export const StandardRescueDialog = (props: Props) => { // open // ); - const linodeDisks = disks?.map((disk) => - assoc('_id', `disk-${disk.id}`, disk) - ); + const linodeDisks = disks?.map((disk) => ({ + ...disk, + _id: `disk-${disk.id}`, + })); const filteredVolumes = volumes?.filter((volume) => { @@ -147,7 +146,13 @@ export const StandardRescueDialog = (props: Props) => { const [APIError, setAPIError] = React.useState(''); React.useEffect(() => { - if (!equals(deviceMap, prevDeviceMap)) { + if ( + Object.entries(deviceMap).length !== + Object.entries(prevDeviceMap ?? {}).length || + Object.entries(deviceMap).some( + ([key, value]) => prevDeviceMap?.[key as keyof DeviceMap] !== value + ) + ) { setCounter(initialCounter); setRescueDevices(deviceMap); setAPIError(''); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResizeUnifiedMigrationPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResizeUnifiedMigrationPanel.tsx index 0c9a72f8baf..6b172f20279 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResizeUnifiedMigrationPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeResize/LinodeResizeUnifiedMigrationPanel.tsx @@ -7,11 +7,11 @@ import { RadioGroup, TooltipIcon, } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { Link } from 'src/components/Link'; -import { capitalize } from 'src/utilities/capitalize'; import type { MigrationTypes } from '@linode/api-v4/lib/linodes'; import type { ResizeLinodePayload } from '@linode/api-v4/lib/linodes/types'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/AlertSection.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/AlertSection.tsx index baee422cfd9..73e1875d540 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/AlertSection.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/AlertSection.tsx @@ -9,7 +9,7 @@ import { fadeIn, } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; interface Props { @@ -65,9 +65,11 @@ export const AlertSection = (props: Props) => { display: 'flex', flexDirection: 'column', }} - lg={7} - md={9} - xs={12} + size={{ + lg: 7, + md: 9, + xs: 12, + }} > { paddingLeft: '78px', }, }} - lg={5} - md={3} - xs={12} + size={{ + lg: 5, + md: 3, + xs: 12, + }} > void; onPasswordChange: (password: string) => void; password: string; - passwordError?: string; + passwordError: string | undefined; selectedImage: Image['id']; setAuthorizedUsers: (usernames: string[]) => void; } @@ -24,8 +22,8 @@ interface Props { export const ImageAndPassword = (props: Props) => { const { authorizedUsers, + disabled, imageFieldError, - linodeId, onImageChange, onPasswordChange, password, @@ -34,16 +32,8 @@ export const ImageAndPassword = (props: Props) => { setAuthorizedUsers, } = props; - const { data: grants } = useGrants(); - const { data: profile } = useProfile(); - - const disabled = - profile?.restricted && - grants?.linode.find((g) => g.id === linodeId)?.permissions !== 'read_write'; - return ( - {disabled && } { value={selectedImage} variant="all" /> - onPasswordChange(e.target.value)} + value={password || ''} + /> + + ); }; - -const StyledAccessPanel = styled(AccessPanel, { label: 'StyledAccessPanel' })( - ({ theme }) => ({ - margin: `${theme.spacing(3)} 0 ${theme.spacing(3)} 0`, - padding: 0, - }) -); diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx index 08262e4b789..d835eab7e7d 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/InterfaceSelect.tsx @@ -7,7 +7,7 @@ import { Typography, } from '@linode/ui'; import { 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'; @@ -310,16 +310,31 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { container spacing={isSmallBp ? 0 : 4} > - + {jsxSelectVLAN} - + {jsxIPAMForVLAN} ) : ( - + {jsxSelectVLAN} {jsxIPAMForVLAN} @@ -349,12 +364,16 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { {fromAddonsPanel ? null : ( <> - + {errors.primaryError && ( )} - + { regionHasVLANs !== false && enclosingJSXForVLANFields(jsxSelectVLAN, jsxIPAMForVLAN)} {purpose === 'vpc' && regionHasVPCs !== false && ( - + handleIPv4Input( @@ -413,7 +432,6 @@ export const InterfaceSelect = (props: InterfaceSelectProps) => { /> )} - {!fromAddonsPanel && ( kernelID === option.value); }; -export const groupKernels = (kernel: Kernel) => { - if (kernel.label.match(/latest/i)) { - return 'Current'; - } - if (['GRUB (Legacy)', 'GRUB 2'].includes(kernel.label)) { - return 'Current'; - } - if (kernel.label === 'Direct Disk') { - return 'Current'; - } - if (kernel.deprecated) { - return 'Deprecated'; - } - if (kernel.architecture === 'x86_64') { - return '64 bit'; - } else if (kernel.architecture === 'i386') { - return '32 bit'; - } - // Fallback; this should never happen. - return 'Current'; -}; - export const kernelsToGroupedItems = (kernels: Kernel[]) => { - const groupedKernels = groupBy(groupKernels, kernels); + const groupedKernels: { [index: string]: Kernel[] } = {}; + kernels.forEach((kernel) => { + let group = ''; + if ( + kernel.label.match(/latest/i) || + ['GRUB (Legacy)', 'GRUB 2'].includes(kernel.label) || + kernel.label === 'Direct Disk' + ) { + group = 'Current'; + } else if (kernel.deprecated) { + group = 'Deprecated'; + } else if (kernel.architecture === 'x86_64') { + group = '64 bit'; + } else if (kernel.architecture === 'i386') { + group = '32 bit'; + } else { + group = 'Current'; + } + if (Array.isArray(groupedKernels[group])) { + groupedKernels[group].push({ ...kernel }); + } else { + groupedKernels[group] = [{ ...kernel }]; + } + }); groupedKernels.Current = sortCurrentKernels(groupedKernels.Current); return Object.keys(groupedKernels) diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsPasswordPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsPasswordPanel.tsx index 551cbe9d7a2..77cda2e779a 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsPasswordPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeSettingsPasswordPanel.tsx @@ -16,8 +16,10 @@ import { import { useTypeQuery } from 'src/queries/types'; import { getErrorMap } from 'src/utilities/errorUtils'; -const PasswordInput = React.lazy( - () => import('src/components/PasswordInput/PasswordInput') +const PasswordInput = React.lazy(() => + import('src/components/PasswordInput/PasswordInput').then((module) => ({ + default: module.PasswordInput, + })) ); interface Props { diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx index 3b90e511891..b12903d5246 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSettings/LinodeWatchdogPanel.tsx @@ -8,7 +8,7 @@ import { Toggle, Typography, } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { @@ -37,13 +37,24 @@ export const LinodeWatchdogPanel = (props: Props) => { defaultExpanded heading="Shutdown Watchdog" > - + {Boolean(error) && ( - + )} - + { disabled={isReadOnly} /> - + Shutdown Watchdog, also known as Lassie, is a Linode Manager feature capable of automatically rebooting your Linode if it powers off diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx index 7fb1b6431af..7f0cd35c6a4 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeStorage/CreateDiskDrawer.tsx @@ -16,6 +16,7 @@ import * as React from 'react'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { Drawer } from 'src/components/Drawer'; import { ModeSelect } from 'src/components/ModeSelect/ModeSelect'; +import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted'; import { useEventsPollingActions } from 'src/queries/events/events'; import { useAllLinodeDisksQuery, @@ -24,6 +25,7 @@ import { import { useLinodeQuery } from 'src/queries/linodes/linodes'; import { handleAPIErrors } from 'src/utilities/formikErrorUtils'; +import { LinodePermissionsError } from '../LinodePermissionsError'; import { ImageAndPassword } from '../LinodeSettings/ImageAndPassword'; import type { Image } from '@linode/api-v4'; @@ -63,6 +65,12 @@ export const CreateDiskDrawer = (props: Props) => { const { data: disks } = useAllLinodeDisksQuery(linodeId, open); + const disabled = useIsResourceRestricted({ + grantLevel: 'read_only', + grantType: 'linode', + id: linodeId, + }); + const { mutateAsync: createDisk, reset } = useLinodeDiskCreateMutation( linodeId ); @@ -129,6 +137,7 @@ export const CreateDiskDrawer = (props: Props) => { return (
    + {disabled && } setSelectedMode(e.target.value as CreateMode)} @@ -144,6 +153,7 @@ export const CreateDiskDrawer = (props: Props) => { )} { (option) => option.label === formik.values.filesystem )} disableClearable + disabled={disabled} label="Filesystem" onBlur={formik.handleBlur} options={fileSystemOptions} @@ -188,7 +199,7 @@ export const CreateDiskDrawer = (props: Props) => { formik.setFieldValue('authorized_users', value) } authorizedUsers={formik.values.authorized_users} - linodeId={linodeId} + disabled={Boolean(disabled)} password={formik.values.root_pass} selectedImage={formik.values.image} /> @@ -198,6 +209,7 @@ export const CreateDiskDrawer = (props: Props) => { endAdornment: MB, }} data-qa-disk-size + disabled={disabled} errorText={formik.touched.size ? formik.errors.size : undefined} label="Size" name="size" @@ -213,6 +225,7 @@ export const CreateDiskDrawer = (props: Props) => { { }) => { return ( - + diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx index 250858ed7e2..c3b03a42e9b 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeSummary/LinodeSummary.tsx @@ -1,13 +1,12 @@ -import { Autocomplete, Paper, Stack, Typography } from '@linode/ui'; +import { Autocomplete, ErrorState, Paper, Stack, Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { DateTime } from 'luxon'; import * as React from 'react'; import { useParams } from 'react-router-dom'; import PendingIcon from 'src/assets/icons/pending.svg'; import { AreaChart } from 'src/components/AreaChart/AreaChart'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { STATS_NOT_READY_API_MESSAGE, STATS_NOT_READY_MESSAGE, @@ -235,7 +234,7 @@ const LinodeSummary = (props: Props) => { return ( - + { sx={{ mt: 1, width: 150 }} /> - + { /> - + { return ( <> - + ( @@ -129,7 +134,12 @@ export const NetworkGraphs = (props: Props) => { /> - + ( diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetail.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetail.tsx index 645a7018fc6..a25ff842c6e 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetail.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetail.tsx @@ -1,4 +1,4 @@ -import { CircleProgress } from '@linode/ui'; +import { CircleProgress, ErrorState } from '@linode/ui'; import { createLazyRoute } from '@tanstack/react-router'; import * as React from 'react'; import { @@ -10,7 +10,6 @@ import { useRouteMatch, } from 'react-router-dom'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { useLinodeQuery } from 'src/queries/linodes/linodes'; import { getQueryParamsFromQueryString } from 'src/utilities/queryParams'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx index d1c6eb1da15..a97afe3a4c7 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/LinodeDetailHeader.tsx @@ -1,8 +1,7 @@ -import { CircleProgress } from '@linode/ui'; +import { CircleProgress, ErrorState } from '@linode/ui'; import * as React from 'react'; import { useHistory, useLocation, useRouteMatch } from 'react-router-dom'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; import { ProductInformationBanner } from 'src/components/ProductInformationBanner/ProductInformationBanner'; import { LinodeEntityDetail } from 'src/features/Linodes/LinodeEntityDetail'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MigrationNotification.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MigrationNotification.tsx index e3f1cbb916e..52bf3fe29cf 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MigrationNotification.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailHeader/MigrationNotification.tsx @@ -1,5 +1,6 @@ import { scheduleOrQueueMigration } from '@linode/api-v4/lib/linodes'; import { Notice, StyledLinkButton, Typography } from '@linode/ui'; +import { capitalize } from '@linode/utilities'; import { DateTime } from 'luxon'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -8,7 +9,6 @@ import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { useDialog } from 'src/hooks/useDialog'; import { useProfile } from 'src/queries/profile/profile'; -import { capitalize } from 'src/utilities/capitalize'; import { parseAPIDate } from 'src/utilities/date'; import { formatDate } from 'src/utilities/formatDate'; import { pluralize } from 'src/utilities/pluralize'; diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailNavigation.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailNavigation.tsx index 7a91fdb9c66..59b796e3195 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailNavigation.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodesDetailNavigation.tsx @@ -1,5 +1,5 @@ -import { CircleProgress } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import { CircleProgress, ErrorState } from '@linode/ui'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { matchPath, @@ -10,7 +10,6 @@ import { import { DismissibleBanner } from 'src/components/DismissibleBanner/DismissibleBanner'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { TabLinkList } from 'src/components/Tabs/TabLinkList'; @@ -21,8 +20,10 @@ import { useLinodeQuery } from 'src/queries/linodes/linodes'; import { useTypeQuery } from 'src/queries/types'; const LinodeSummary = React.lazy(() => import('./LinodeSummary/LinodeSummary')); -const LinodeNetwork = React.lazy( - () => import('./LinodeNetworking/LinodeNetwork') +const LinodeNetworking = React.lazy(() => + import('./LinodeNetworking/LinodeNetworking').then((module) => ({ + default: module.LinodeNetworking, + })) ); const LinodeStorage = React.lazy(() => import('./LinodeStorage/LinodeStorage')); const LinodeConfigurations = React.lazy( @@ -128,7 +129,7 @@ const LinodesDetailNavigation = () => { spacingTop={32} variant="warning" > - {text} + {text} ) : null } @@ -142,7 +143,7 @@ const LinodesDetailNavigation = () => { - + {isBareMetalInstance ? null : ( <> diff --git a/packages/manager/src/features/Linodes/LinodesLanding/CardView.tsx b/packages/manager/src/features/Linodes/LinodesLanding/CardView.tsx index 613e957b7ba..cbc240d37cf 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/CardView.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/CardView.tsx @@ -1,6 +1,6 @@ import { Typography } from '@linode/ui'; +import Grid from '@mui/material/Grid2'; import { keyframes, styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { LinodeEntityDetail } from 'src/features/Linodes/LinodeEntityDetail'; @@ -29,7 +29,7 @@ export const CardView = (props: RenderLinodesProps) => { {data.map((linode, idx: number) => ( - + diff --git a/packages/manager/src/features/Linodes/LinodesLanding/DisplayGroupedLinodes.tsx b/packages/manager/src/features/Linodes/LinodesLanding/DisplayGroupedLinodes.tsx index b6968cfee64..8119f134b2f 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/DisplayGroupedLinodes.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/DisplayGroupedLinodes.tsx @@ -1,7 +1,7 @@ -import { IconButton } from '@linode/ui'; import { Box, CircleProgress, Paper, Tooltip, Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; -import { compose } from 'ramda'; +import { IconButton } from '@linode/ui'; +import { groupByTags, sortGroups } from '@linode/utilities'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import GridView from 'src/assets/icons/grid-view.svg'; @@ -19,7 +19,6 @@ import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { useInfinitePageSize } from 'src/hooks/useInfinitePageSize'; -import { groupByTags, sortGroups } from 'src/utilities/groupByTags'; import { StyledControlHeader, @@ -80,7 +79,7 @@ export const DisplayGroupedLinodes = (props: DisplayGroupedLinodesProps) => { const dataLength = data.length; - const orderedGroupedLinodes = compose(sortGroups, groupByTags)(data); + const orderedGroupedLinodes = sortGroups(groupByTags(data)); const tableWrapperProps = { dataLength, handleOrderChange, @@ -102,7 +101,7 @@ export const DisplayGroupedLinodes = (props: DisplayGroupedLinodesProps) => { if (display === 'grid') { return ( <> - + {isGeckoLAEnabled && ( @@ -161,7 +160,7 @@ export const DisplayGroupedLinodes = (props: DisplayGroupedLinodesProps) => { return ( - + {tag} @@ -201,7 +200,7 @@ export const DisplayGroupedLinodes = (props: DisplayGroupedLinodesProps) => { return ( - + { )} {display === 'grid' && ( <> - + {isGeckoLAEnabled && ( { )} )} - + { { open={this.state.linodeMigrateOpen} /> (props: SortableTableHeadProps) => {
    Currently in {linodeViewPreference} view diff --git a/packages/manager/src/features/Linodes/LinodesLanding/TableWrapper.tsx b/packages/manager/src/features/Linodes/LinodesLanding/TableWrapper.tsx index ffb21a84cef..f00f1821a25 100644 --- a/packages/manager/src/features/Linodes/LinodesLanding/TableWrapper.tsx +++ b/packages/manager/src/features/Linodes/LinodesLanding/TableWrapper.tsx @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { Table } from 'src/components/Table'; @@ -40,7 +40,7 @@ const TableWrapper = (props: TableWrapperProps) => { return ( - +
    { const handlePaste = (event: ClipboardEvent) => { event.preventDefault(); - if (!ref.current?.rfb) { + if ( + !ref.current?.rfb || + ref.current.rfb._rfbConnectionState !== 'connected' + ) { return; } if (event.clipboardData === null) { @@ -147,6 +148,12 @@ const sendCharacter = ( character: string, ref: React.RefObject ) => { + if ( + !ref.current?.rfb || + ref.current.rfb._rfbConnectionState !== 'connected' + ) { + return; + } const actualCharacter = character[0]; const requiresShift = actualCharacter.match(/[A-Z!@#$%^&*()_+{}:\"<>?~|]/); diff --git a/packages/manager/src/features/Lish/Lish.tsx b/packages/manager/src/features/Lish/Lish.tsx index f935de8126a..30b6fe4afec 100644 --- a/packages/manager/src/features/Lish/Lish.tsx +++ b/packages/manager/src/features/Lish/Lish.tsx @@ -1,9 +1,8 @@ -import { CircleProgress } from '@linode/ui'; +import { CircleProgress, ErrorState } from '@linode/ui'; import { styled } from '@mui/material/styles'; import * as React from 'react'; import { useHistory, useParams } from 'react-router-dom'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { TabLinkList } from 'src/components/Tabs/TabLinkList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; diff --git a/packages/manager/src/features/Lish/Weblish.tsx b/packages/manager/src/features/Lish/Weblish.tsx index b1c40220f4d..bdad1037915 100644 --- a/packages/manager/src/features/Lish/Weblish.tsx +++ b/packages/manager/src/features/Lish/Weblish.tsx @@ -1,9 +1,8 @@ /* eslint-disable scanjs-rules/call_addEventListener */ -import { CircleProgress } from '@linode/ui'; +import { CircleProgress, ErrorState } from '@linode/ui'; import { Terminal } from '@xterm/xterm'; import * as React from 'react'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { ParsePotentialLishErrorString, RetryLimiter, diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ActiveConnections.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ActiveConnections.tsx index 38a91c3d470..19c1f0a8905 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ActiveConnections.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ActiveConnections/ActiveConnections.tsx @@ -1,6 +1,6 @@ import { Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import Paginate from 'src/components/Paginate'; @@ -30,7 +30,12 @@ export const ActiveConnections = (props: TableProps) => { const theme = useTheme(); return ( - + { return ( - + { {version && {version}} - - + { return ( - + { title="Requests" {...graphProps} /> - - + + - + { {...graphProps} /> - + { /> - + ({ diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Disks/Disks.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Disks/Disks.tsx index d0626888f48..d687ec4ca25 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Disks/Disks.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Disks/Disks.tsx @@ -1,8 +1,7 @@ -import { CircleProgress } from '@linode/ui'; +import { CircleProgress, ErrorState } from '@linode/ui'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Placeholder } from 'src/components/Placeholder/Placeholder'; import { useGraphs } from '../OverviewGraphs/useGraphs'; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/GaugesSection.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/GaugesSection.tsx index be1bec54a97..12ff3431095 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/GaugesSection.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/GaugesSection.tsx @@ -1,6 +1,4 @@ -import { APIError } from '@linode/api-v4/lib/types'; -import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { CPUGauge } from '../../LongviewLanding/Gauges/CPU'; @@ -10,6 +8,8 @@ import { RAMGauge } from '../../LongviewLanding/Gauges/RAM'; import { StorageGauge } from '../../LongviewLanding/Gauges/Storage'; import { SwapGauge } from '../../LongviewLanding/Gauges/Swap'; +import type { APIError } from '@linode/api-v4'; + interface Props { clientID: number; lastUpdatedError?: APIError[]; @@ -17,61 +17,49 @@ interface Props { export const GaugesSection = React.memo((props: Props) => { return ( - - + + - - + + - - + + - - + + - - + + - - + + - - + + ); }); - -const StyledGaugeContainerGrid = styled(Grid, { - label: 'StyledGaugeContainerGrid', -})(({ theme }) => ({ - marginBottom: theme.spacing(6), - boxSizing: 'border-box', -})); - -const StyledOuterGrid = styled(Grid, { label: 'StyledOuterGrid' })( - ({ theme }) => ({ - [theme.breakpoints.down('lg')]: { - marginBottom: theme.spacing(2), - }, - [theme.breakpoints.up('lg')]: { - maxWidth: 450, - }, - }) -); diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/IconSection.styles.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/IconSection.styles.tsx deleted file mode 100644 index de916377ca2..00000000000 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/IconSection.styles.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { styled, SxProps } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2/Grid2'; -import { IconTextLink } from 'src/components/IconTextLink/IconTextLink'; - -const sxGrid: SxProps = { - boxSizing: 'border-box', - display: 'flex', - flexWrap: 'wrap', - width: '100%', - wrap: 'nowrap', -}; - -export const StyledHeaderGrid = styled(Grid, { label: 'StyledHeaderGrid' })({ - ...sxGrid, - alignItems: 'flex-start', -}); - -export const StyledIconContainerGrid = styled(Grid, { - label: 'StyledIconContainerGrid', -})({ - '&:last-of-type': { - marginBottom: 0, - }, - alignItems: 'center', - ...sxGrid, -}); - -export const StyledIconGrid = styled(Grid, { label: 'StyledIconGrid' })({ - '& svg': { - display: 'block', - margin: '0 auto', - }, -}); - -export const StyledIconTextLink = styled(IconTextLink, { - label: 'StyledIconTextLink', -})(({ theme }) => ({ - '& g': { - stroke: theme.palette.primary.main, - }, - '& svg': { - marginRight: 18, - }, - fontSize: '0.875rem', - padding: 0, -})); - -export const StyledPackageGrid = styled(Grid, { label: 'StyledPackageGrid' })({ - alignSelf: 'center', - boxSizing: 'border-box', -}); diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/IconSection.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/IconSection.tsx index 2488bb4319f..3c9dd3c30ac 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/IconSection.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/IconSection.tsx @@ -1,5 +1,5 @@ -import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2/Grid2'; +import { Box, Stack, Typography } from '@linode/ui'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import CPUIcon from 'src/assets/icons/longview/cpu-icon.svg'; @@ -7,6 +7,7 @@ import DiskIcon from 'src/assets/icons/longview/disk.svg'; import PackageIcon from 'src/assets/icons/longview/package-icon.svg'; import RamIcon from 'src/assets/icons/longview/ram-sticks.svg'; import ServerIcon from 'src/assets/icons/longview/server-icon.svg'; +import { IconTextLink } from 'src/components/IconTextLink/IconTextLink'; import { formatUptime } from 'src/utilities/formatUptime'; import { readableBytes } from 'src/utilities/unitConversions'; @@ -15,13 +16,6 @@ import { getTotalMemoryUsage, sumStorage, } from '../../shared/utilities'; -import { - StyledHeaderGrid, - StyledIconContainerGrid, - StyledIconGrid, - StyledIconTextLink, - StyledPackageGrid, -} from './IconSection.styles'; import type { Props as LVDataProps } from 'src/containers/longview.stats.container'; @@ -71,104 +65,113 @@ export const IconSection = React.memo((props: Props) => { const storageInBytes = sumStorage(props.longviewClientData.Disk); + const items = [ + { + content: ( + + {osDist} {osDistVersion} {kernel && `(${kernel})`} + + ), + icon: , + }, + { + content: ( + <> + {cpuType} + {cpuCoreCount && ( + {`${cpuCoreCount} ${coreCountDisplay}`} + )} + + ), + icon: , + }, + { + content: ( + + {convertedTotalMemory.value !== 0 && + convertedTotalSwap.value !== 0 ? ( + + + {`${convertedTotalMemory.value} ${convertedTotalMemory.unit} RAM`} + + + {`${convertedTotalSwap.value} ${convertedTotalSwap.unit} Swap`} + + + ) : ( + + RAM information not available + + )} + + ), + icon: , + }, + { + content: ( + + {storageInBytes.total !== 0 ? ( + + + {`${ + readableBytes(storageInBytes.total, { unit: 'GB' }).formatted + } Storage`} + + + {`${ + readableBytes(storageInBytes.free, { unit: 'GB' }).formatted + } Available`} + + + ) : ( + + Storage information not available + + )} + + ), + icon: , + }, + ]; + return ( - - - + + + {props.client} {hostname} {formattedUptime} - - - - - - - - - {osDist} {osDistVersion} {kernel && `(${kernel})`} - - - - - - - - - {cpuType} - {cpuCoreCount && ( - {`${cpuCoreCount} ${coreCountDisplay}`} - )} - - - - - - - {convertedTotalMemory.value !== 0 && convertedTotalSwap.value !== 0 ? ( - - - {`${convertedTotalMemory.value} ${convertedTotalMemory.unit} RAM`} - - - {`${convertedTotalSwap.value} ${convertedTotalSwap.unit} Swap`} - - - ) : ( - - RAM information not available - - )} - - - - - - - {storageInBytes.total !== 0 ? ( - - - {`${ - readableBytes(storageInBytes.total, { unit: 'GB' }).formatted - } Storage`} - - - {`${ - readableBytes(storageInBytes.free, { unit: 'GB' }).formatted - } Available`} - - - ) : ( - - Storage information not available - - )} - - {packages && packages.length > 0 ? ( - - - + {items.map(({ content, icon }, index) => ( + + + {icon} + + {content} + + ))} + {packages && ( + + {packagesToUpdate} - - - - ) : ( - - - - - - {packagesToUpdate} - - - )} + + + )} + ); }); diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/ListeningServices.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/ListeningServices.tsx index 0de96fbbd88..fb44ed6fe95 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/ListeningServices.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ListeningServices/ListeningServices.tsx @@ -1,5 +1,5 @@ import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import Paginate from 'src/components/Paginate'; @@ -28,7 +28,12 @@ export const ListeningServices = (props: TableProps) => { const { services, servicesError, servicesLoading } = props; return ( - + ({ [theme.breakpoints.down('lg')]: { diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/LongviewDetailOverview.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/LongviewDetailOverview.tsx index 220317edf31..23b91c631dd 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/LongviewDetailOverview.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/LongviewDetailOverview.tsx @@ -1,12 +1,11 @@ import { Paper } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LongviewPackageDrawer } from '../../LongviewPackageDrawer'; import { ActiveConnections } from './ActiveConnections/ActiveConnections'; -import { StyledItemGrid } from './CommonStyles.styles'; import { GaugesSection } from './GaugesSection'; import { IconSection } from './IconSection'; import { ListeningServices } from './ListeningServices/ListeningServices'; @@ -74,14 +73,14 @@ export const LongviewDetailOverview = (props: Props) => { - + - { topProcessesError={topProcessesError} topProcessesLoading={topProcessesLoading} /> - + - + - { connectionsError={portsError} connectionsLoading={listeningPortsLoading && !lastUpdated} /> - + { return ( - + { timezone={timezone} title="Queries" /> - - + + - + { unit={'/s'} /> - + { /> - - + + - + { title="Slow Queries" /> - + { /> - + { return ( - + { {version && {version}} - - - + + { start={time.start} timezone={timezone} /> - + ); }); diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINX.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINX.tsx index ac5e9297590..56683109ec2 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINX.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINX.tsx @@ -1,16 +1,13 @@ import { Box, Notice, Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { Link } from 'src/components/Link'; +import { TimeRangeSelect } from 'src/features/Longview/shared/TimeRangeSelect'; import { isToday as _isToday } from 'src/utilities/isToday'; -import { - StyledItemGrid, - StyledTimeRangeSelect, - StyledTypography, -} from '../CommonStyles.styles'; +import { StyledTypography } from '../CommonStyles.styles'; import { useGraphs } from '../OverviewGraphs/useGraphs'; import { NGINXGraphs } from './NGINXGraphs'; @@ -94,7 +91,7 @@ export const NGINX = React.memo((props: Props) => { return ( - + { NGINX {version && {version}} - - - + + { start={time.start} timezone={timezone} /> - + ); }); diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINXGraphs.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINXGraphs.tsx index eb88d924324..840692213e2 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINXGraphs.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/NGINX/NGINXGraphs.tsx @@ -1,18 +1,15 @@ +import Grid from '@mui/material/Grid2'; import { useTheme } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { LongviewLineGraph } from 'src/components/LongviewLineGraph/LongviewLineGraph'; -import { LongviewProcesses, NginxResponse } from '../../../request.types'; import { convertData } from '../../../shared/formatters'; -import { - StyledItemGrid, - StyledRootPaper, - StyledSmallGraphGrid, -} from '../CommonStyles.styles'; +import { StyledRootPaper, StyledSmallGraphGrid } from '../CommonStyles.styles'; import { ProcessGraphs } from '../ProcessGraphs'; +import type { LongviewProcesses, NginxResponse } from '../../../request.types'; + interface Props { data?: NginxResponse; end: number; @@ -55,7 +52,7 @@ export const NGINXGraphs = React.memo((props: Props) => { return ( - + { unit=" requests/s" {...graphProps} /> - - + + - + { {...graphProps} /> - + { /> - + { return ( - + - - - + + { start={time.start} timezone={timezone} /> - + ); }; diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/OverviewGraphs.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/OverviewGraphs.tsx index cd056965ae7..a2bf6a51716 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/OverviewGraphs.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/OverviewGraphs/OverviewGraphs.tsx @@ -1,20 +1,20 @@ +import { Paper } from '@linode/ui'; +import Grid from '@mui/material/Grid2'; import { styled, useTheme } from '@mui/material/styles'; import * as React from 'react'; -import Grid from '@mui/material/Unstable_Grid2'; -import { Paper } from '@linode/ui'; import { isToday as _isToday } from 'src/utilities/isToday'; -import { WithStartAndEnd } from '../../../request.types'; import { TimeRangeSelect } from '../../../shared/TimeRangeSelect'; - import { StyledTypography } from '../CommonStyles.styles'; import { CPUGraph } from './CPUGraph'; import { DiskGraph } from './DiskGraph'; import { LoadGraph } from './LoadGraph'; import { MemoryGraph } from './MemoryGraph'; import { NetworkGraph } from './NetworkGraph'; -import { GraphProps } from './types'; + +import type { WithStartAndEnd } from '../../../request.types'; +import type { GraphProps } from './types'; interface Props { clientAPIKey: string; @@ -51,7 +51,7 @@ export const OverviewGraphs = (props: Props) => { return ( - + Resource Allocation History @@ -75,7 +75,7 @@ export const OverviewGraphs = (props: Props) => { - + { }} > - + - + - + - + - + diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ProcessGraphs.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ProcessGraphs.tsx index 0ab74d3d3f3..96e7a1a5f4f 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ProcessGraphs.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/ProcessGraphs.tsx @@ -1,5 +1,5 @@ +import Grid from '@mui/material/Grid2'; import { useTheme } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; import * as React from 'react'; import { LongviewLineGraph } from 'src/components/LongviewLineGraph/LongviewLineGraph'; @@ -8,13 +8,14 @@ import { readableBytes, } from 'src/utilities/unitConversions'; -import { LongviewProcesses } from '../../request.types'; import { convertData, formatMemory } from '../../shared/formatters'; import { statMax, sumRelatedProcessesAcrossAllUsers, } from '../../shared/utilities'; -import { StyledItemGrid, StyledSmallGraphGrid } from './CommonStyles.styles'; +import { StyledSmallGraphGrid } from './CommonStyles.styles'; + +import type { LongviewProcesses } from '../../request.types'; interface Props { data: LongviewProcesses; @@ -63,9 +64,9 @@ export const ProcessGraphs = React.memo((props: Props) => { return ( <> - + - + { {...graphProps} /> - + { /> - - + + - + { {...graphProps} /> - + { /> - + ); }); diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx index 964e5d1dea7..c37e4e523f6 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/Processes/ProcessesLanding.tsx @@ -1,13 +1,12 @@ import { TextField } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import { escapeRegExp } from '@linode/utilities'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { statAverage, statMax } from 'src/features/Longview/shared/utilities'; -import { escapeRegExp } from 'src/utilities/escapeRegExp'; import { isToday as _isToday } from 'src/utilities/isToday'; -import { StyledItemGrid } from '../CommonStyles.styles'; import { useGraphs } from '../OverviewGraphs/useGraphs'; import { ProcessesGraphs } from './ProcessesGraphs'; import { StyledBox, StyledTimeRangeSelect } from './ProcessesLanding.styles'; @@ -116,7 +115,7 @@ export const ProcessesLanding = React.memo((props: Props) => { <> - + ) => @@ -147,8 +146,8 @@ export const ProcessesLanding = React.memo((props: Props) => { selectedProcess={selectedProcess} setSelectedProcess={setSelectedProcess} /> - - + + { time={time} timezone={timezone} /> - + ); diff --git a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx index 58cf502b2cc..975c225e369 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/DetailTabs/TopProcesses.tsx @@ -1,4 +1,5 @@ import { Box, Typography } from '@linode/ui'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { Table } from 'src/components/Table'; @@ -14,7 +15,6 @@ import { useOrderV2 } from 'src/hooks/useOrderV2'; import { readableBytes } from 'src/utilities/unitConversions'; import { formatCPU } from '../../shared/formatters'; -import { StyledItemGrid } from './CommonStyles.styles'; import { StyledLink } from './TopProcesses.styles'; import type { APIError } from '@linode/api-v4/lib/types'; @@ -63,7 +63,7 @@ export const TopProcesses = React.memo((props: Props) => { : undefined; return ( - + Top Processes @@ -113,7 +113,7 @@ export const TopProcesses = React.memo((props: Props) => { )}
    - +
    ); }); diff --git a/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx b/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx index eb4dbb10690..d745895f536 100644 --- a/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx +++ b/packages/manager/src/features/Longview/LongviewDetail/LongviewDetail.tsx @@ -1,8 +1,7 @@ -import { CircleProgress, Notice, Paper } from '@linode/ui'; +import { CircleProgress, ErrorState, Notice, Paper } from '@linode/ui'; import * as React from 'react'; import { compose } from 'recompose'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { LandingHeader } from 'src/components/LandingHeader'; import { NotFound } from 'src/components/NotFound'; import { SuspenseLoader } from 'src/components/SuspenseLoader'; diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.styles.ts b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.styles.ts index 8c3b3e8a760..681d4a876e3 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.styles.ts +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.styles.ts @@ -1,6 +1,6 @@ import { Button } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2/Grid2'; +import Grid from '@mui/material/Grid2'; export const StyledButton = styled(Button, { label: 'StyledButton' })({ '&:hover': { diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.tsx index 37509e5b09b..763b8090b44 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientHeader.tsx @@ -1,5 +1,5 @@ import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2/Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { compose } from 'recompose'; diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientInstructions.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientInstructions.tsx index c6dc3c1bd19..255c3d17f46 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewClientInstructions.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewClientInstructions.tsx @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Unstable_Grid2/Grid2'; +import Grid from '@mui/material/Grid2'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; @@ -62,8 +62,13 @@ export const LongviewClientInstructions = (props: Props) => { container data-testid="installation" > - - + + {userCanModifyClient ? ( { /> )} - + - - + + { spacing={2} wrap="nowrap" > - - + + { /> - + - + - + - + - + - + { - + ({ diff --git a/packages/manager/src/features/Longview/LongviewLanding/LongviewList.tsx b/packages/manager/src/features/Longview/LongviewLanding/LongviewList.tsx index 35d7daef317..b478889a05b 100644 --- a/packages/manager/src/features/Longview/LongviewLanding/LongviewList.tsx +++ b/packages/manager/src/features/Longview/LongviewLanding/LongviewList.tsx @@ -1,13 +1,13 @@ import { Box, CircleProgress, + ErrorState, Paper, StyledLinkButton, Typography, } from '@linode/ui'; import * as React from 'react'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import Paginate from 'src/components/Paginate'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; diff --git a/packages/manager/src/features/Longview/shared/InstallationInstructions.styles.ts b/packages/manager/src/features/Longview/shared/InstallationInstructions.styles.ts index 4827104cbd9..db082f64bcf 100644 --- a/packages/manager/src/features/Longview/shared/InstallationInstructions.styles.ts +++ b/packages/manager/src/features/Longview/shared/InstallationInstructions.styles.ts @@ -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 StyledInstructionGrid = styled(Grid, { label: 'StyledInstructionGrid', diff --git a/packages/manager/src/features/Longview/shared/InstallationInstructions.tsx b/packages/manager/src/features/Longview/shared/InstallationInstructions.tsx index 316c1fb83f2..b4f68860469 100644 --- a/packages/manager/src/features/Longview/shared/InstallationInstructions.tsx +++ b/packages/manager/src/features/Longview/shared/InstallationInstructions.tsx @@ -1,6 +1,6 @@ import { Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; @@ -31,7 +31,7 @@ export const InstallationInstructions = React.memo((props: Props) => { receiving data. - + @@ -63,14 +63,14 @@ export const InstallationInstructions = React.memo((props: Props) => { - + This should work for most installations, but if you have issues, please consult our troubleshooting guide and manual installation instructions (API key required): - + diff --git a/packages/manager/src/features/Managed/Contacts/Contacts.styles.tsx b/packages/manager/src/features/Managed/Contacts/Contacts.styles.tsx index 84a43527dc2..8dabce001b1 100644 --- a/packages/manager/src/features/Managed/Contacts/Contacts.styles.tsx +++ b/packages/manager/src/features/Managed/Contacts/Contacts.styles.tsx @@ -1,6 +1,6 @@ import { Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; export const StyledWrapperGrid = styled(Grid, { label: 'StyledWrapperGrid' })( ({ theme }) => ({ diff --git a/packages/manager/src/features/Managed/Contacts/ContactsDrawer.tsx b/packages/manager/src/features/Managed/Contacts/ContactsDrawer.tsx index 454beae5f4b..89a89a76567 100644 --- a/packages/manager/src/features/Managed/Contacts/ContactsDrawer.tsx +++ b/packages/manager/src/features/Managed/Contacts/ContactsDrawer.tsx @@ -1,6 +1,6 @@ import { Notice, Select, TextField } from '@linode/ui'; import { createContactSchema } from '@linode/validation/lib/managed.schema'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { Formik } from 'formik'; import * as React from 'react'; @@ -146,7 +146,6 @@ const ContactsDrawer = (props: ContactsDrawerProps) => { variant="error" /> )} - { /> - + { value={values?.phone?.primary ?? ''} /> - + import('src/components/PasswordInput/PasswordInput') +const PasswordInput = React.lazy(() => + import('src/components/PasswordInput/PasswordInput').then((module) => ({ + default: module.PasswordInput, + })) ); export interface CredentialDrawerProps { diff --git a/packages/manager/src/features/Managed/Credentials/UpdateCredentialDrawer.tsx b/packages/manager/src/features/Managed/Credentials/UpdateCredentialDrawer.tsx index f73f2794f26..bae52d87b50 100644 --- a/packages/manager/src/features/Managed/Credentials/UpdateCredentialDrawer.tsx +++ b/packages/manager/src/features/Managed/Credentials/UpdateCredentialDrawer.tsx @@ -11,8 +11,10 @@ import { updateLabelSchema, updatePasswordSchema } from './credential.schema'; import type { CredentialPayload } from '@linode/api-v4/lib/managed'; -const PasswordInput = React.lazy( - () => import('src/components/PasswordInput/PasswordInput') +const PasswordInput = React.lazy(() => + import('src/components/PasswordInput/PasswordInput').then((module) => ({ + default: module.PasswordInput, + })) ); export interface CredentialDrawerProps { diff --git a/packages/manager/src/features/Managed/ManagedDashboardCard/DashboardCard.styles.tsx b/packages/manager/src/features/Managed/ManagedDashboardCard/DashboardCard.styles.tsx index 05fbf3d1fe3..d75fefec817 100644 --- a/packages/manager/src/features/Managed/ManagedDashboardCard/DashboardCard.styles.tsx +++ b/packages/manager/src/features/Managed/ManagedDashboardCard/DashboardCard.styles.tsx @@ -1,5 +1,5 @@ import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { styled } from '@mui/material/styles'; export const StyledRootGrid = styled(Grid, { label: 'StyledRootGrid' })( diff --git a/packages/manager/src/features/Managed/ManagedDashboardCard/DashboardCard.tsx b/packages/manager/src/features/Managed/ManagedDashboardCard/DashboardCard.tsx index f20790e31ec..52f4f4988c5 100644 --- a/packages/manager/src/features/Managed/ManagedDashboardCard/DashboardCard.tsx +++ b/packages/manager/src/features/Managed/ManagedDashboardCard/DashboardCard.tsx @@ -1,5 +1,5 @@ import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { @@ -34,7 +34,7 @@ const DashboardCard = (props: DashboardCardProps) => { return ( {(title || headerAction) && ( - + { )} - {props.children} + {props.children} ); }; diff --git a/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedChartPanel.tsx b/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedChartPanel.tsx index b90453a8c9f..d05d48e832b 100644 --- a/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedChartPanel.tsx +++ b/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedChartPanel.tsx @@ -1,9 +1,8 @@ -import { Box, CircleProgress, Typography } from '@linode/ui'; +import { Box, CircleProgress, ErrorState, Typography } from '@linode/ui'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; import { AreaChart } from 'src/components/AreaChart/AreaChart'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { TabbedPanel } from 'src/components/TabbedPanel/TabbedPanel'; import { convertNetworkToUnit, diff --git a/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedDashboardCard.styles.tsx b/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedDashboardCard.styles.tsx index a2e60077e06..8feb94065c6 100644 --- a/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedDashboardCard.styles.tsx +++ b/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedDashboardCard.styles.tsx @@ -1,4 +1,4 @@ -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { styled } from '@mui/material/styles'; import DashboardCard from './DashboardCard'; @@ -35,12 +35,3 @@ export const StyledDashboardCard = styled(DashboardCard, { }, width: '100%', })); - -export const StyledStatusGrid = styled(Grid, { label: 'StyledStatusGrid' })( - ({ theme }) => ({ - position: 'relative', - [theme.breakpoints.up('sm')]: { - margin: `${theme.spacing(3)} ${theme.spacing(1)} !important`, - }, - }) -); diff --git a/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedDashboardCard.tsx b/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedDashboardCard.tsx index 9bad1f8d7fe..3d2c1444baa 100644 --- a/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedDashboardCard.tsx +++ b/packages/manager/src/features/Managed/ManagedDashboardCard/ManagedDashboardCard.tsx @@ -1,8 +1,7 @@ -import { CircleProgress } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import { CircleProgress, ErrorState } from '@linode/ui'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { useAllManagedIssuesQuery, useAllManagedMonitorsQuery, @@ -14,7 +13,6 @@ import { StyledDashboardCard, StyledMonitorStatusOuterGrid, StyledOuterContainerGrid, - StyledStatusGrid, } from './ManagedDashboardCard.styles'; import MonitorStatus from './MonitorStatus'; import MonitorTickets from './MonitorTickets'; @@ -58,13 +56,12 @@ export const ManagedDashboardCard = () => { direction="row" justifyContent="center" > - @@ -72,7 +69,7 @@ export const ManagedDashboardCard = () => { - + diff --git a/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorStatus.styles.tsx b/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorStatus.styles.tsx index f6875a68e89..59b3016c27c 100644 --- a/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorStatus.styles.tsx +++ b/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorStatus.styles.tsx @@ -1,6 +1,6 @@ import { Typography } from '@linode/ui'; import { styled } from '@mui/material/styles'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; export const StyledTypography = styled(Typography, { label: 'StyledTypography', diff --git a/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorStatus.tsx b/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorStatus.tsx index 365bbe1f794..e1fee6a410d 100644 --- a/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorStatus.tsx +++ b/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorStatus.tsx @@ -1,5 +1,5 @@ import { 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 'react-router-dom'; diff --git a/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorTickets.styles.tsx b/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorTickets.styles.tsx index 4cd0ad2bdf8..87296b06c10 100644 --- a/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorTickets.styles.tsx +++ b/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorTickets.styles.tsx @@ -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'; export const StyledButton = styled(Button, { label: 'StyledButton' })( ({ theme }) => ({ diff --git a/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorTickets.tsx b/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorTickets.tsx index 8526124a6cd..815227a163e 100644 --- a/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorTickets.tsx +++ b/packages/manager/src/features/Managed/ManagedDashboardCard/MonitorTickets.tsx @@ -1,5 +1,5 @@ import { Typography } from '@linode/ui'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import * as React from 'react'; import { Link, useHistory } from 'react-router-dom'; diff --git a/packages/manager/src/features/Managed/MonitorDrawer.tsx b/packages/manager/src/features/Managed/MonitorDrawer.tsx index 0ca078079b9..e9772efc121 100644 --- a/packages/manager/src/features/Managed/MonitorDrawer.tsx +++ b/packages/manager/src/features/Managed/MonitorDrawer.tsx @@ -6,7 +6,7 @@ import { TextField, } from '@linode/ui'; import { createServiceMonitorSchema } from '@linode/validation/lib/managed.schema'; -import Grid from '@mui/material/Unstable_Grid2'; +import Grid from '@mui/material/Grid2'; import { Formik } from 'formik'; import * as React from 'react'; @@ -190,7 +190,12 @@ const MonitorDrawer = (props: MonitorDrawerProps) => { /> - +